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

jose-torres pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 45a831a8cc07 [SPARK-57378][SDP] Implement SCD2 Batch Processor; Merge 
Reconciled Rows into Aux and Target Tables
45a831a8cc07 is described below

commit 45a831a8cc079225461d67f540ccc5350b1f525c
Author: Andreas Neumann <[email protected]>
AuthorDate: Thu Jul 23 22:09:23 2026 -0700

    [SPARK-57378][SDP] Implement SCD2 Batch Processor; Merge Reconciled Rows 
into Aux and Target Tables
    
    Note: This continues AnishMahto's #56457, opened from my fork while the 
original author is out of office. It carries his implement merge into aux and 
target + self-review commits and adds review fixes and additional test coverage 
on top. #56457 should be closed in favor of this PR to keep review in one place.
    
    ### What changes were proposed in this pull request?
    This is the next step in the SCD2 AutoCDC batch-processor series (following 
SPARK-57356, "Cleanup Delete Encoding Rows  Post-Reconciliation"). It adds the 
final merge stage that writes a reconciled microbatch into the auxiliary and 
target tables. Three methods are added to Scd2BatchProcessor:
    
      - identifyAndTagAuxRows — tags each post-reconciliation row with 
shouldRouteToAuxTableColName, marking which rows belong in the auxiliary table 
(tombstones and other delete-boundary encodings) versus the visible target 
table.
      - mergeRowsIntoAuxiliaryTable — merges the reconciled rows tagged for the 
auxiliary table into it, handling tombstone insertion/advancement and 
deletedByBatchId-scoped garbage collection.
      - mergeRowsIntoTargetTable — merges the reconciled visible rows into the 
target table (the user-facing SCD2 history).
    
    Together with the reconciliation and cleanup steps already merged, this 
completes the per-microbatch transformation pipeline; the foreachBatch callback 
that drives it end-to-end lands in the follow-up (SPARK-57395).
    
    ### Why are the changes needed?
    The SCD2 batch processor is being built incrementally across the 
SPARK-571xx series. After a microbatch is preprocessed, decomposed, reconciled, 
and cleaned up, its rows must actually be written to the auxiliary and target 
tables using the correct MERGE semantics. This PR supplies that final merge 
step, which the engine's SCD2 execution path depends on.
    
    ### Does this PR introduce any user-facing change?
    No. These are internal (private[autocdc]) batch-processor methods; SCD2 
AutoCDC is not yet reachable end-to-end (the driving foreachBatch handler and 
the API/planner wiring land in separate changes).
    
    ### How was this patch tested?
    New unit tests:
      - Scd2BatchProcessorMergeSuite — covers the aux/target merge behavior 
(tombstone insertion and advancement, GC of superseded aux rows, target-table 
history writes).
      - Additional cases in Scd2BatchProcessorSuite — identifyAndTagAuxRows 
routing, per-key window isolation, dotted / identifier-sensitive column names, 
and combined-branch / multi-column merges.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Generated-by: Claude Code (Opus 4.8)
    
    Closes #57444 from anew/SPARK-57378-merge-reconciled-rows.
    
    Lead-authored-by: Andreas Neumann <[email protected]>
    Co-authored-by: Anish Mahto <[email protected]>
    Signed-off-by: Jose Torres <[email protected]>
    (cherry picked from commit 0687ebf64c6370d55190f6b372f55119ed72831c)
    Signed-off-by: Jose Torres <[email protected]>
---
 .../sql/pipelines/autocdc/Scd2BatchProcessor.scala | 334 +++++++++++++++-
 .../autocdc/Scd2BatchProcessorMergeSuite.scala     | 440 +++++++++++++++++++++
 .../autocdc/Scd2BatchProcessorSuite.scala          | 310 +++++++++++++++
 3 files changed, 1083 insertions(+), 1 deletion(-)

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 6e5af84216b5..34bd8a5d2929 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,11 +20,13 @@ 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.TableIdentifier
 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, ExpressionUtils}
 import org.apache.spark.sql.expressions.{Window, WindowSpec}
-import org.apache.spark.sql.types.{BooleanType, DataType, StringType, 
StructField, StructType}
+import org.apache.spark.sql.types.{BooleanType, DataType, LongType, 
StringType, StructField,
+  StructType}
 import org.apache.spark.util.ArrayImplicits._
 
 /**
@@ -942,6 +944,315 @@ case class Scd2BatchProcessor(
       .fieldNames
       .toImmutableArraySeq
   }
+
+  /**
+   * Tag each post-reconciliation row with 
[[Scd2BatchProcessor.shouldRouteToAuxTableColName]]:
+   * `true` for rows that belong in the auxiliary table (tombstones and hidden 
no-op upserts),
+   * `false` for rows that do not. The flag's contract is solely about 
aux-table membership; it
+   * makes no claim about whether a `false` row belongs in the target table.
+   *
+   * @param reconciledDf the canonical post-reconciliation rows for the 
affected keys.
+   * @return `reconciledDf` with an added boolean
+   *         [[Scd2BatchProcessor.shouldRouteToAuxTableColName]] column; no 
rows are added or
+   *         removed.
+   */
+  private[autocdc] def identifyAndTagAuxRows(reconciledDf: DataFrame): 
DataFrame = {
+    val recordStartAt =
+      
Scd2BatchProcessor.recordStartAtOf(F.col(AutoCdcReservedNames.cdcMetadataColName))
+    val startAt = F.col(Scd2BatchProcessor.startAtColName)
+    val endAt = F.col(Scd2BatchProcessor.endAtColName)
+    val current = Scd2IntervalColumns(recordStartAt, startAt, endAt)
+    val next = current.leadBy(1, orderChronologicallyPerKeyWindow)
+
+    val trackedHistoryColumns = computeTrackedHistoryColumns(reconciledDf)
+    val areTrackedColumnsEqualInNextRow = trackedHistoryColumns
+      .map { c =>
+        val col = F.col(QuotingUtils.quoteIdentifier(c))
+        col <=> F.lead(col, 1).over(orderChronologicallyPerKeyWindow)
+      }
+      .reduceOption(_ && _)
+      .getOrElse(F.lit(true))
+
+    val isTombstone = RowClassifier.isTombstone(current)
+    val isHiddenNoOpUpsert = RowClassifier.isNoOpUpsertContinuation(
+      row = current,
+      next = next,
+      areTrackedColumnsEqual = areTrackedColumnsEqualInNextRow
+    )
+
+    reconciledDf.withColumn(
+      Scd2BatchProcessor.shouldRouteToAuxTableColName,
+      isTombstone || isHiddenNoOpUpsert
+    )
+  }
+
+  /**
+   * Merge the reconciled rows that belong in the auxiliary table (tombstones 
and hidden no-op
+   * upserts, as tagged by [[identifyAndTagAuxRows]]) onto the auxiliary 
table, and logically
+   * delete any previously-affected aux row that did not survive 
reconciliation.
+   *
+   * Idempotency across `foreachBatch` retries: rows this batch logically 
deletes are not
+   * physically removed but stamped with 
[[Scd2BatchProcessor.deletedByBatchIdColName]] `=
+   * batchId`, so a retry of the same `batchId` still observes them via
+   * [[findAffectedRowsFromAuxiliaryTable]] and re-derives the same 
reconciliation output. Aux
+   * rows logically deleted by an older, already-committed batch
+   * ([[Scd2BatchProcessor.deletedByBatchIdColName]] `!= batchId`) are 
physically
+   * garbage-collected as part of this same merge.
+   *
+   * @param reconciledDfWithAuxRowsTagged reconciled rows tagged by 
[[identifyAndTagAuxRows]].
+   * @param originalAffectedRowsFromAuxiliaryTable the affected aux rows 
pulled in for this
+   *        microbatch, in canonical SCD2 row schema (i.e. as returned by
+   *        [[findAffectedRowsFromAuxiliaryTable]], with the aux-only
+   *        [[Scd2BatchProcessor.deletedByBatchIdColName]] already dropped).
+   * @param auxiliaryTableIdentifier the identifier of the auxiliary table to 
merge into.
+   * @param batchId the underlying Spark streaming query's batchId, used to 
stamp logical deletes
+   *        and scope garbage collection.
+   */
+  private[autocdc] def mergeRowsIntoAuxiliaryTable(
+      reconciledDfWithAuxRowsTagged: DataFrame,
+      originalAffectedRowsFromAuxiliaryTable: DataFrame,
+      auxiliaryTableIdentifier: TableIdentifier,
+      batchId: Long): Unit = {
+    val resolver = 
reconciledDfWithAuxRowsTagged.sparkSession.sessionState.conf.resolver
+    val deletedByBatchIdCol = Scd2BatchProcessor.deletedByBatchIdColName
+
+    val reconciledAuxRows = reconciledDfWithAuxRowsTagged
+      .filter(F.col(Scd2BatchProcessor.shouldRouteToAuxTableColName))
+      .drop(Scd2BatchProcessor.shouldRouteToAuxTableColName)
+
+    // All of the reconciledAuxRows will be landing in the aux table as alive 
(non-deleted) rows,
+    // so tag them with a null deleted-by batch id.
+    val auxRowsToUpsert = reconciledAuxRows
+      .withColumn(deletedByBatchIdCol, F.lit(null).cast(LongType))
+
+    // Any aux row pulled in for reconciliation but absent from the 
post-reconciliation aux rows
+    // was either dropped as redundant or promoted to the (now-visible) target 
table. Either way
+    // it must leave the aux table; stamp it with this batch's id for 
deleted-by batch id. On this
+    // batch's MERGE, the existing aux row will be considered logically 
deleted. In a future
+    // microbatch's merge, it will be physically deleted.
+    val auxRowsToDelete = antiJoinRowsByRecordStartAtPerKey(
+        leftRows = originalAffectedRowsFromAuxiliaryTable,
+        rightRows = reconciledAuxRows
+      )
+      .withColumn(deletedByBatchIdCol, F.lit(batchId))
+
+    val mergeSource = auxRowsToUpsert
+      .unionByName(auxRowsToDelete)
+      .as("source")
+
+    // At this point [[mergeSource]] must have the same columns/shape as the 
persisted aux table.
+    val auxTableColumns = mergeSource.columns.toImmutableArraySeq
+
+    // Build predicate for whether rows represent the same event (match). In 
SCD2 each key can have
+    // multiple records, so a row's identity is defined by (keys, sequencing).
+    val auxIdentQuoted = auxiliaryTableIdentifier.quotedString
+    val meta = AutoCdcReservedNames.cdcMetadataColName
+
+    val mergeSourceRecordStartAt =
+      Scd2BatchProcessor.recordStartAtOf(F.col(s"source.`$meta`"))
+    val auxTableRecordStartAt =
+      Scd2BatchProcessor.recordStartAtOf(F.col(s"$auxIdentQuoted.`$meta`"))
+
+    val doKeysMatch = changeArgs.keys
+      .map(k => F.col(s"source.${k.quoted}") === 
F.col(s"$auxIdentQuoted.${k.quoted}"))
+      .reduce(_ && _)
+    val doRowsMatch = doKeysMatch && (mergeSourceRecordStartAt <=> 
auxTableRecordStartAt)
+
+    // On updates, MERGE requires only non-key columns are updated (remapped). 
For inserts, all of
+    // the row's columns must explicitly be mapped.
+    val keyNames = changeArgs.keys.map(_.name)
+    def upsertAssignments(columnName: String): (String, Column) = {
+      val quoted = QuotingUtils.quoteIdentifier(columnName)
+      s"$auxIdentQuoted.$quoted" -> F.col(s"source.$quoted")
+    }
+    val nonKeyUpdateAssignments = auxTableColumns
+      .filterNot(c => keyNames.exists(resolver(_, c)))
+      .map(upsertAssignments)
+      .toMap
+    val insertAssignments = auxTableColumns.map(upsertAssignments).toMap
+
+    // Physically garbage-collect aux rows logically deleted by some other, 
already-committed
+    // batch. Such rows are always excluded when pulling in the current 
microbatch's affected set,
+    // so they can never match against a row being merged in.
+    val auxTableDeletedByBatchId = 
F.col(s"$auxIdentQuoted.`$deletedByBatchIdCol`")
+    val isGarbageCollectableAuxRow =
+      auxTableDeletedByBatchId.isNotNull && auxTableDeletedByBatchId =!= 
F.lit(batchId)
+
+    // Whether a row in the MERGE source represents a row being [logically] 
deleted, as opposed to
+    // being upserted.
+    val shouldLogicallyDeleteAuxRow = 
F.col(s"source.`$deletedByBatchIdCol`").isNotNull
+
+    mergeSource
+      .mergeInto(auxIdentQuoted, doRowsMatch)
+      // Keys in source row match against existing row in aux table, and 
declare intent to delete
+      // the corresponding row in the aux; mark the row as logically deleted 
in the aux table.
+      .whenMatched(shouldLogicallyDeleteAuxRow)
+      .update(
+        Map(
+          s"$auxIdentQuoted.`$deletedByBatchIdCol`" ->
+            F.col(s"source.`$deletedByBatchIdCol`")
+        )
+      )
+      // Keys in source row match against existing row in aux table and does 
not represent a row
+      // being deleted; update the data/operational columns
+      .whenMatched(!shouldLogicallyDeleteAuxRow)
+      .update(nonKeyUpdateAssignments)
+      // Keys in source do not match against an existing row in aux table and 
does not represent a
+      // row being deleted; insert the new key's row.
+      .whenNotMatched(!shouldLogicallyDeleteAuxRow)
+      .insert(insertAssignments)
+      // If this is a row not affected by the current microbatch but is 
eligible for garbage
+      // collection now, proactively hard-delete it.
+      // TODO: This GC triggers a full scan of the aux table; revisit whether 
to GC only
+      // periodically rather than on every microbatch.
+      .whenNotMatchedBySource(isGarbageCollectableAuxRow)
+      .delete()
+      .merge()
+  }
+
+  /**
+   * Merge the reconciled rows that are visible in the target table (run tails 
that are
+   * upsert-representing and not routed to the aux table) onto the target 
table, and delete any
+   * previously-affected target row that did not survive reconciliation.
+   *
+   * A previously-affected target row is deleted when it is absent from the 
post-reconciliation
+   * visible rows. This covers both (a) a row reconciled away entirely (e.g. 
closed by a
+   * coincident tombstone) and (b) a row demoted to a hidden aux row (e.g. a 
previously-visible
+   * no-op tail superseded within its run by a later no-op upsert). In either 
case the
+   * corresponding target row must go.
+   *
+   * @param reconciledDfWithAuxRowsTagged reconciled rows tagged by 
[[identifyAndTagAuxRows]].
+   * @param affectedRowsFromTargetTable the affected target rows pulled in for 
this microbatch.
+   * @param targetTableIdentifier the identifier of the target table to merge 
into.
+   */
+  private[autocdc] def mergeRowsIntoTargetTable(
+      reconciledDfWithAuxRowsTagged: DataFrame,
+      affectedRowsFromTargetTable: DataFrame,
+      targetTableIdentifier: TableIdentifier): Unit = {
+    val targetIdentQuoted = targetTableIdentifier.quotedString
+    val meta = AutoCdcReservedNames.cdcMetadataColName
+
+    val resolver = 
reconciledDfWithAuxRowsTagged.sparkSession.sessionState.conf.resolver
+    val keyNames = changeArgs.keys.map(_.name)
+
+    // Find the reconciled rows that should specifically land in the target 
table, as per SCD2.
+    val recordStartAt =
+      
Scd2BatchProcessor.recordStartAtOf(reconciledDfWithAuxRowsTagged.col(meta))
+    val startAt = 
reconciledDfWithAuxRowsTagged.col(Scd2BatchProcessor.startAtColName)
+    val endAt = 
reconciledDfWithAuxRowsTagged.col(Scd2BatchProcessor.endAtColName)
+    val isVisibleTargetRow =
+      // Aux table by definition only holds rows that should not be visible to 
users, but are
+      // required for cross-microbatch stateful reconciliation (tombstones and 
currently no-op
+      // upserts).
+      !F.col(Scd2BatchProcessor.shouldRouteToAuxTableColName) &&
+        // While rows headed to aux are mutually exclusive to rows that should 
land in the target,
+        // it's possible there are rows that belong to neither aux nor target 
(ex. decomposition
+        // tails). Only non-no-op upsert-representing rows should land in the 
target, and no-op
+        // upsert-representing rows would have been marked as routed to aux 
table; hence all
+        // remaining upserts should land in the target table.
+        
RowClassifier.isUpsertRepresentingRow(Scd2IntervalColumns(recordStartAt, 
startAt, endAt))
+
+    // Compute the set of affected rows that should be upserted back into the 
target table, as well
+    // as rows that have been dropped or demoted out of the target table.
+    val reconciledTargetRows = reconciledDfWithAuxRowsTagged
+      .filter(isVisibleTargetRow)
+      .drop(Scd2BatchProcessor.shouldRouteToAuxTableColName)
+
+    val rowsToDeleteFromTarget = antiJoinRowsByRecordStartAtPerKey(
+      leftRows = affectedRowsFromTargetTable,
+      rightRows = reconciledTargetRows
+    )
+
+    // At this point, [[reconciledTargetRows]] shares the same columns/shape 
as the target table.
+    val targetTableColumns = reconciledTargetRows.columns.toImmutableArraySeq
+
+    // Unlike the aux table the target table does not persist any delete 
marker (logical or not),
+    // so we need to augment a column to all MERGE source rows to indicate 
whether the row
+    // represents that a corresponding target table row should be deleted. 
Once we explicitly mark
+    // each row as a deletion or not, we can union them without losing 
information.
+    val shouldDeleteTargetRowCol = 
Scd2BatchProcessor.shouldDeleteTargetRowColName
+    val mergeSource = reconciledTargetRows
+      .withColumn(shouldDeleteTargetRowCol, F.lit(false))
+      .unionByName(rowsToDeleteFromTarget.withColumn(shouldDeleteTargetRowCol, 
F.lit(true)))
+      .select(
+        targetTableColumns.map(c => F.col(QuotingUtils.quoteIdentifier(c))) :+
+          F.col(shouldDeleteTargetRowCol): _*
+      )
+      .as("source")
+
+    val mergeSourceRecordStartAt = 
Scd2BatchProcessor.recordStartAtOf(F.col(s"source.`$meta`"))
+    val targetTableRecordStartAt =
+      Scd2BatchProcessor.recordStartAtOf(F.col(s"$targetIdentQuoted.`$meta`"))
+    val doKeysMatch = changeArgs.keys
+      .map(k => F.col(s"source.${k.quoted}") === 
F.col(s"$targetIdentQuoted.${k.quoted}"))
+      .reduce(_ && _)
+    val doRowsMatch = doKeysMatch && (mergeSourceRecordStartAt <=> 
targetTableRecordStartAt)
+
+    // On updates, MERGE requires only non-key columns are updated (remapped). 
For inserts, all of
+    // the row's columns must explicitly be mapped.
+    def upsertAssignments(columnName: String): (String, Column) = {
+      val quoted = QuotingUtils.quoteIdentifier(columnName)
+      s"$targetIdentQuoted.$quoted" -> F.col(s"source.$quoted")
+    }
+    val nonKeyUpdateAssignments = targetTableColumns
+      .filterNot(c => keyNames.exists(resolver(_, c)))
+      .map(upsertAssignments)
+      .toMap
+    val insertAssignments = targetTableColumns.map(upsertAssignments).toMap
+
+    // Whether a row in the MERGE source represents a row being deleted, as 
opposed to being
+    // upserted. Unlike the aux table, target deletes are physical (hard) 
deletes.
+    val shouldDeleteTargetRow = F.col(s"source.`$shouldDeleteTargetRowCol`")
+
+    mergeSource
+      .mergeInto(targetIdentQuoted, doRowsMatch)
+      // Keys in source row match against an existing row in the target table, 
and declare intent
+      // to delete the corresponding row; physically remove it from the target 
table.
+      .whenMatched(shouldDeleteTargetRow)
+      .delete()
+      // Keys in source row match against an existing row in the target table 
and does not
+      // represent a row being deleted; update the data/operational columns.
+      .whenMatched(!shouldDeleteTargetRow)
+      .update(nonKeyUpdateAssignments)
+      // Keys in source do not match against an existing row in the target 
table and does not
+      // represent a row being deleted; insert the new key's row.
+      .whenNotMatched(!shouldDeleteTargetRow)
+      .insert(insertAssignments)
+      .merge()
+  }
+
+  /**
+   * Left-anti-join `leftRows` against `rightRows`, matching on the key 
columns and
+   * [[Scd2BatchProcessor.recordStartAtFieldName]] (null-safe). Returns the 
`leftRows` that have
+   * no counterpart in `rightRows` - i.e. the rows that were dropped between 
the two sets -
+   * preserving `leftRows`' schema.
+   *
+   * Both inputs must carry the key columns and the 
[[AutoCdcReservedNames.cdcMetadataColName]]
+   * column.
+   */
+  private[autocdc] def antiJoinRowsByRecordStartAtPerKey(
+      leftRows: DataFrame,
+      rightRows: DataFrame): DataFrame = {
+    val leftAlias = "left"
+    val rightAlias = "right"
+    val leftRecordStartAt = Scd2BatchProcessor.recordStartAtOf(
+      F.col(s"$leftAlias.`${AutoCdcReservedNames.cdcMetadataColName}`")
+    )
+    val rightRecordStartAt = Scd2BatchProcessor.recordStartAtOf(
+      F.col(s"$rightAlias.`${AutoCdcReservedNames.cdcMetadataColName}`")
+    )
+    val doKeysMatch = changeArgs.keys
+      .map(k => F.col(s"$leftAlias.${k.quoted}") === 
F.col(s"$rightAlias.${k.quoted}"))
+      .reduce(_ && _)
+
+    leftRows
+      .as(leftAlias)
+      .join(
+        rightRows.as(rightAlias),
+        doKeysMatch && (leftRecordStartAt <=> rightRecordStartAt),
+        joinType = "left_anti"
+      )
+  }
 }
 
 /**
@@ -1166,6 +1477,27 @@ object Scd2BatchProcessor {
    */
   private val anchorSequenceColName: String = 
s"${AutoCdcReservedNames.prefix}anchor_sequence"
 
+  /**
+   * Name of the temporary column projected by 
[[Scd2BatchProcessor.identifyAndTagAuxRows]] to
+   * mark rows destined for the auxiliary table (tombstones and hidden no-op 
upserts) rather than
+   * the target table.
+   *
+   * Temporary in that the column has no observable side effect or persistence 
across microbatches.
+   */
+  private[autocdc] val shouldRouteToAuxTableColName: String =
+    s"${AutoCdcReservedNames.prefix}should_route_to_aux_table"
+
+  /**
+   * Name of the temporary column used by 
[[Scd2BatchProcessor.mergeRowsIntoTargetTable]] to flag,
+   * within the unioned merge source, which rows should be deleted from 
(rather than upserted
+   * into) the target table.
+   *
+   * Temporary in that the column has no observable side effect or persistence 
across
+   * microbatches.
+   */
+  private val shouldDeleteTargetRowColName: String =
+    s"${AutoCdcReservedNames.prefix}should_delete_target_row"
+
   /** Project the [[recordStartAtFieldName]] out of an SCD2 CDC metadata 
column. */
   private def recordStartAtOf(cdcMetadataCol: Column): Column =
     cdcMetadataCol.getField(recordStartAtFieldName)
diff --git 
a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessorMergeSuite.scala
 
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessorMergeSuite.scala
new file mode 100644
index 000000000000..f77a35fecd15
--- /dev/null
+++ 
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessorMergeSuite.scala
@@ -0,0 +1,440 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.pipelines.autocdc
+
+import org.scalatest.BeforeAndAfter
+
+import org.apache.spark.sql.{functions => F, QueryTest, Row}
+import org.apache.spark.sql.classic.DataFrame
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types._
+
+/**
+ * Catalog-backed unit tests for the SCD2 `MERGE INTO` routines
+ * [[Scd2BatchProcessor.mergeRowsIntoAuxiliaryTable]] and
+ * [[Scd2BatchProcessor.mergeRowsIntoTargetTable]]. These exercise every merge 
branch (insert,
+ * update, delete, and - for the aux table - logical delete and garbage 
collection) against an
+ * in-memory v2 row-level catalog.
+ */
+class Scd2BatchProcessorMergeSuite
+    extends QueryTest
+    with SharedSparkSession
+    with BeforeAndAfter
+    with AutoCdcCatalogExecutionTestBase {
+
+  private val userSchema = new StructType()
+    .add("id", IntegerType)
+    .add("value", StringType)
+
+  /** Canonical SCD2 row schema: user columns + framework start/end + cdc 
metadata struct. */
+  private val canonicalSchema = userSchema
+    .add(Scd2BatchProcessor.startAtColName, LongType, nullable = true)
+    .add(Scd2BatchProcessor.endAtColName, LongType, nullable = true)
+    .add(
+      AutoCdcReservedNames.cdcMetadataColName,
+      Scd2BatchProcessor.cdcMetadataColSchema(LongType),
+      nullable = false
+    )
+
+  /** Auxiliary table schema: canonical schema plus the aux-only 
logical-delete marker column. */
+  private val auxSchema = canonicalSchema
+    .add(Scd2BatchProcessor.deletedByBatchIdColName, LongType, nullable = true)
+
+  /** Tagged schema consumed by the merges: canonical schema plus the routing 
flag. */
+  private val taggedSchema = canonicalSchema
+    .add(Scd2BatchProcessor.shouldRouteToAuxTableColName, BooleanType, 
nullable = false)
+
+  /** User schema with a dotted (identifier-sensitive) non-key column name. */
+  private val dottedUserSchema = new StructType()
+    .add("id", IntegerType)
+    .add("user.name", StringType)
+
+  /** Canonical schema variant whose user column name contains a dot. */
+  private val dottedCanonicalSchema = dottedUserSchema
+    .add(Scd2BatchProcessor.startAtColName, LongType, nullable = true)
+    .add(Scd2BatchProcessor.endAtColName, LongType, nullable = true)
+    .add(
+      AutoCdcReservedNames.cdcMetadataColName,
+      Scd2BatchProcessor.cdcMetadataColSchema(LongType),
+      nullable = false
+    )
+
+  /** Aux schema variant whose user column name contains a dot. */
+  private val dottedAuxSchema = dottedCanonicalSchema
+    .add(Scd2BatchProcessor.deletedByBatchIdColName, LongType, nullable = true)
+
+  /** Tagged schema variant whose user column name contains a dot. */
+  private val dottedTaggedSchema = dottedCanonicalSchema
+    .add(Scd2BatchProcessor.shouldRouteToAuxTableColName, BooleanType, 
nullable = false)
+
+  /** User schema with several non-key columns, to exercise the non-key update 
assignment map. */
+  private val wideUserSchema = new StructType()
+    .add("id", IntegerType)
+    .add("name", StringType)
+    .add("status", StringType)
+    .add("score", IntegerType)
+
+  /** Canonical schema variant with several non-key user columns. */
+  private val wideCanonicalSchema = wideUserSchema
+    .add(Scd2BatchProcessor.startAtColName, LongType, nullable = true)
+    .add(Scd2BatchProcessor.endAtColName, LongType, nullable = true)
+    .add(
+      AutoCdcReservedNames.cdcMetadataColName,
+      Scd2BatchProcessor.cdcMetadataColSchema(LongType),
+      nullable = false
+    )
+
+  /** Aux schema variant with several non-key user columns. */
+  private val wideAuxSchema = wideCanonicalSchema
+    .add(Scd2BatchProcessor.deletedByBatchIdColName, LongType, nullable = true)
+
+  /** Tagged schema variant with several non-key user columns. */
+  private val wideTaggedSchema = wideCanonicalSchema
+    .add(Scd2BatchProcessor.shouldRouteToAuxTableColName, BooleanType, 
nullable = false)
+
+  private val processor = Scd2BatchProcessor(
+    changeArgs = ChangeArgs(
+      keys = Seq(UnqualifiedColumnName("id")),
+      sequencing = F.col("seq"),
+      storedAsScdType = ScdType.Type2
+    ),
+    resolvedSequencingType = LongType
+  )
+
+  /** Build a tagged-rows [[DataFrame]] (canonical columns + routing flag) for 
the merges. */
+  private def taggedOf(rows: Row*): DataFrame = 
microbatchOf(taggedSchema)(rows: _*)
+
+  /** Build a canonical SCD2 [[DataFrame]] (e.g. the `findAffected*` outputs) 
for the merges. */
+  private def canonicalOf(rows: Row*): DataFrame = 
microbatchOf(canonicalSchema)(rows: _*)
+
+  private def createAuxTable(seedRows: Row*): Unit =
+    createTable(defaultAuxIdent, defaultAuxTableIdentifier, auxSchema, 
seedRows: _*)
+
+  private def createTargetTable(seedRows: Row*): Unit =
+    createTable(defaultTargetIdent, defaultTargetTableIdentifier, 
canonicalSchema, seedRows: _*)
+
+  private def auxTable: DataFrame =
+    spark.read.table(defaultAuxTableIdentifier.quotedString)
+
+  private def targetTable: DataFrame =
+    spark.read.table(defaultTargetTableIdentifier.quotedString)
+
+  // 
===========================================================================================
+  // mergeRowsIntoAuxiliaryTable tests
+  // 
===========================================================================================
+
+  test("mergeRowsIntoAuxiliaryTable inserts a routed row absent from the aux 
table") {
+    createAuxTable()
+
+    // A freshly-routed tombstone with no matching aux row is inserted live 
(no logical-delete
+    // marker).
+    val tagged = taggedOf(Row(1, "x", 5L, 5L, Row(5L), true))
+    val affected = canonicalOf()
+
+    processor.mergeRowsIntoAuxiliaryTable(tagged, affected, 
defaultAuxTableIdentifier, batchId = 1L)
+
+    checkAnswer(auxTable, Row(1, "x", 5L, 5L, Row(5L), null))
+  }
+
+  test("mergeRowsIntoAuxiliaryTable updates a surviving routed row in place") {
+    // An existing hidden no-op upsert at recordStartAt=5 that survives 
reconciliation.
+    createAuxTable(Row(1, "old", 5L, null, Row(5L), null))
+
+    val tagged = taggedOf(Row(1, "new", 5L, null, Row(5L), true))
+    // The affected row survives (still present in the routed set), so it is 
not logically
+    // deleted; only its non-key columns are refreshed.
+    val affected = canonicalOf(Row(1, "old", 5L, null, Row(5L)))
+
+    processor.mergeRowsIntoAuxiliaryTable(tagged, affected, 
defaultAuxTableIdentifier, batchId = 2L)
+
+    checkAnswer(auxTable, Row(1, "new", 5L, null, Row(5L), null))
+  }
+
+  test("mergeRowsIntoAuxiliaryTable logically deletes an affected row that did 
not survive") {
+    // A tombstone pulled in as affected but absent from this batch's routed 
set.
+    createAuxTable(Row(1, "gone", 7L, 7L, Row(7L), null))
+
+    // The single tagged row is not routed to the aux table, so the affected 
aux row has no
+    // surviving counterpart and must be logically deleted.
+    val tagged = taggedOf(Row(1, "vis", 10L, null, Row(10L), false))
+    val affected = canonicalOf(Row(1, "gone", 7L, 7L, Row(7L)))
+
+    processor.mergeRowsIntoAuxiliaryTable(tagged, affected, 
defaultAuxTableIdentifier, batchId = 3L)
+
+    // Logical, not physical: the row stays but is stamped with this batch's 
id.
+    checkAnswer(auxTable, Row(1, "gone", 7L, 7L, Row(7L), 3L))
+  }
+
+  test("mergeRowsIntoAuxiliaryTable garbage-collects rows logically deleted by 
an older batch") {
+    createAuxTable(
+      // Logically deleted by an older batch (2 != 5) -> physically 
garbage-collected.
+      Row(1, "gc-old", 7L, 7L, Row(7L), 2L),
+      // Still live (no marker) -> retained.
+      Row(2, "live", 3L, null, Row(3L), null),
+      // Logically deleted by the current batch (5 == 5) -> retained for 
replay-stability.
+      Row(3, "this-batch", 4L, 4L, Row(4L), 5L)
+    )
+
+    // One brand-new routed insert keeps the merge source non-empty; none of 
the seeded rows are
+    // in the affected set, so they are all evaluated by the 
not-matched-by-source GC clause.
+    val tagged = taggedOf(Row(10, "newtomb", 8L, 8L, Row(8L), true))
+    val affected = canonicalOf()
+
+    processor.mergeRowsIntoAuxiliaryTable(tagged, affected, 
defaultAuxTableIdentifier, batchId = 5L)
+
+    checkAnswer(
+      auxTable,
+      Seq(
+        Row(2, "live", 3L, null, Row(3L), null),
+        Row(3, "this-batch", 4L, 4L, Row(4L), 5L),
+        Row(10, "newtomb", 8L, 8L, Row(8L), null)
+      )
+    )
+  }
+
+  test("mergeRowsIntoAuxiliaryTable is replay-stable: re-running the same 
batchId is a no-op") {
+    createAuxTable(
+      // Survives reconciliation -> re-upserted in place.
+      Row(1, "old", 5L, null, Row(5L), null),
+      // Affected but does not survive -> logically deleted by this batch.
+      Row(2, "gone", 7L, 7L, Row(7L), null)
+    )
+
+    val tagged = taggedOf(Row(1, "new", 5L, null, Row(5L), true))
+    val affected = canonicalOf(
+      Row(1, "old", 5L, null, Row(5L)),
+      Row(2, "gone", 7L, 7L, Row(7L))
+    )
+
+    // First application of the batch.
+    processor.mergeRowsIntoAuxiliaryTable(
+      tagged, affected, defaultAuxTableIdentifier, batchId = 7L)
+    val afterFirstRun = auxTable.collect().sortBy(_.getInt(0)).toSeq
+
+    // Replaying the exact same batch (same batchId, re-derived inputs) must 
leave the aux table
+    // byte-identical: the surviving row stays updated in place, and the 
non-surviving row stays
+    // logically deleted by this same batch rather than physically 
garbage-collected (7 ==
+    // batchId, so the GC clause does not fire).
+    processor.mergeRowsIntoAuxiliaryTable(
+      tagged, affected, defaultAuxTableIdentifier, batchId = 7L)
+    val afterSecondRun = auxTable.collect().sortBy(_.getInt(0)).toSeq
+
+    assert(afterFirstRun == afterSecondRun)
+    checkAnswer(
+      auxTable,
+      Seq(
+        Row(1, "new", 5L, null, Row(5L), null),
+        Row(2, "gone", 7L, 7L, Row(7L), 7L)
+      )
+    )
+  }
+
+  test("mergeRowsIntoAuxiliaryTable applies insert, update, logical-delete, 
and GC in one merge") {
+    createAuxTable(
+      // Affected and survives reconciliation -> non-key columns updated in 
place.
+      Row(1, "old", 5L, null, Row(5L), null),
+      // Affected but does not survive -> logically deleted by this batch 
(stamped with batchId).
+      Row(2, "gone", 7L, 7L, Row(7L), null),
+      // Not affected, logically deleted by an older batch (4 != 9) -> 
physically GC'd.
+      Row(3, "gc", 3L, 3L, Row(3L), 4L)
+    )
+
+    val tagged = taggedOf(
+      // Matches key 1 -> update.
+      Row(1, "new", 5L, null, Row(5L), true),
+      // New key 4 routed to aux -> insert.
+      Row(4, "ins", 9L, 9L, Row(9L), true)
+    )
+    // Keys 1 and 2 were pulled in as affected; key 1 survives in the routed 
set, key 2 does not.
+    val affected = canonicalOf(
+      Row(1, "old", 5L, null, Row(5L)),
+      Row(2, "gone", 7L, 7L, Row(7L))
+    )
+
+    processor.mergeRowsIntoAuxiliaryTable(tagged, affected, 
defaultAuxTableIdentifier, batchId = 9L)
+
+    checkAnswer(
+      auxTable,
+      Seq(
+        Row(1, "new", 5L, null, Row(5L), null),
+        Row(2, "gone", 7L, 7L, Row(7L), 9L),
+        Row(4, "ins", 9L, 9L, Row(9L), null)
+        // key 3 physically garbage-collected.
+      )
+    )
+  }
+
+  test("mergeRowsIntoAuxiliaryTable updates every non-key column of a 
surviving row") {
+    createTable(
+      defaultAuxIdent,
+      defaultAuxTableIdentifier,
+      wideAuxSchema,
+      Row(1, "old-name", "active", 10, 5L, null, Row(5L), null)
+    )
+
+    // Every non-key column (name, status, score) differs from the seeded row; 
the in-place update
+    // must refresh all of them, not just the first.
+    val tagged = microbatchOf(wideTaggedSchema)(
+      Row(1, "new-name", "inactive", 42, 5L, null, Row(5L), true)
+    )
+    val affected = microbatchOf(wideCanonicalSchema)(
+      Row(1, "old-name", "active", 10, 5L, null, Row(5L))
+    )
+
+    processor.mergeRowsIntoAuxiliaryTable(tagged, affected, 
defaultAuxTableIdentifier, batchId = 1L)
+
+    checkAnswer(auxTable, Row(1, "new-name", "inactive", 42, 5L, null, 
Row(5L), null))
+  }
+
+  test("mergeRowsIntoAuxiliaryTable handles user columns whose names contain a 
dot") {
+    // A user column named `user.name`: the merge must quote it for both the 
insert column list
+    // and the non-key update assignments. Without quoting, `user.name` would 
be parsed as a
+    // nested-field access and the MERGE would fail to resolve the column.
+    createTable(defaultAuxIdent, defaultAuxTableIdentifier, dottedAuxSchema)
+
+    val tagged = microbatchOf(dottedTaggedSchema)(Row(1, "alice", 5L, 5L, 
Row(5L), true))
+    val affected = microbatchOf(dottedCanonicalSchema)()
+
+    processor.mergeRowsIntoAuxiliaryTable(tagged, affected, 
defaultAuxTableIdentifier, batchId = 1L)
+
+    checkAnswer(auxTable, Row(1, "alice", 5L, 5L, Row(5L), null))
+  }
+
+  // 
===========================================================================================
+  // mergeRowsIntoTargetTable tests
+  // 
===========================================================================================
+
+  test("mergeRowsIntoTargetTable inserts a visible upsert absent from the 
target table") {
+    createTargetTable()
+
+    val tagged = taggedOf(Row(1, "v", 5L, null, Row(5L), false))
+    val affected = canonicalOf()
+
+    processor.mergeRowsIntoTargetTable(tagged, affected, 
defaultTargetTableIdentifier)
+
+    checkAnswer(targetTable, Row(1, "v", 5L, null, Row(5L)))
+  }
+
+  test("mergeRowsIntoTargetTable updates a visible row matched at the same 
recordStartAt") {
+    createTargetTable(Row(1, "old", 5L, null, Row(5L)))
+
+    // The run head at recordStartAt=5 is now closed at 20 with refreshed 
data; it matches and
+    // updates the existing target row in place.
+    val tagged = taggedOf(Row(1, "new", 5L, 20L, Row(5L), false))
+    val affected = canonicalOf(Row(1, "old", 5L, null, Row(5L)))
+
+    processor.mergeRowsIntoTargetTable(tagged, affected, 
defaultTargetTableIdentifier)
+
+    checkAnswer(targetTable, Row(1, "new", 5L, 20L, Row(5L)))
+  }
+
+  test("mergeRowsIntoTargetTable deletes an affected row reconciled away") {
+    createTargetTable(Row(1, "old", 5L, null, Row(5L)))
+
+    // The only tagged row routes to the aux table (e.g. closed into a 
tombstone), so no visible
+    // row survives for key 1: the previously-affected target row must be 
deleted.
+    val tagged = taggedOf(Row(1, "x", 5L, 5L, Row(5L), true))
+    val affected = canonicalOf(Row(1, "old", 5L, null, Row(5L)))
+
+    processor.mergeRowsIntoTargetTable(tagged, affected, 
defaultTargetTableIdentifier)
+
+    assert(targetTable.collect().isEmpty)
+  }
+
+  test("mergeRowsIntoTargetTable only writes visible upsert rows, filtering 
aux/non-upsert rows") {
+    createTargetTable()
+
+    // Only the open upsert (key 1) is visible. The tombstone (routed) and 
hidden no-op (routed)
+    // are filtered by the routing flag; the decomposition tail is filtered 
because it is not an
+    // upsert-representing row.
+    val tagged = taggedOf(
+      Row(1, "vis", 5L, null, Row(5L), false),
+      Row(2, "tomb", 7L, 7L, Row(7L), true),
+      Row(3, "tail", null, 9L, Row(null), false),
+      Row(4, "hidden", 5L, null, Row(5L), true)
+    )
+    val affected = canonicalOf()
+
+    processor.mergeRowsIntoTargetTable(tagged, affected, 
defaultTargetTableIdentifier)
+
+    checkAnswer(targetTable, Row(1, "vis", 5L, null, Row(5L)))
+  }
+
+  test("mergeRowsIntoTargetTable applies insert, update, and delete branches 
in one merge") {
+    createTargetTable(
+      Row(1, "old1", 5L, null, Row(5L)),
+      Row(2, "old2", 8L, null, Row(8L))
+    )
+
+    val tagged = taggedOf(
+      // Matches key 1 at recordStartAt=5 -> update.
+      Row(1, "new1", 5L, 30L, Row(5L), false),
+      // New key 3 -> insert.
+      Row(3, "ins3", 12L, null, Row(12L), false)
+      // Key 2 has no surviving visible row -> delete.
+    )
+    val affected = canonicalOf(
+      Row(1, "old1", 5L, null, Row(5L)),
+      Row(2, "old2", 8L, null, Row(8L))
+    )
+
+    processor.mergeRowsIntoTargetTable(tagged, affected, 
defaultTargetTableIdentifier)
+
+    checkAnswer(
+      targetTable,
+      Seq(
+        Row(1, "new1", 5L, 30L, Row(5L)),
+        Row(3, "ins3", 12L, null, Row(12L))
+      )
+    )
+  }
+
+  test("mergeRowsIntoTargetTable handles user columns whose names contain a 
dot") {
+    // A user column named `user.name`: the merge quotes it for the source 
projection, the insert
+    // column list, and the non-key update assignments. Without quoting, 
`user.name` would be
+    // parsed as a nested-field access and the MERGE would fail to resolve the 
column.
+    createTable(defaultTargetIdent, defaultTargetTableIdentifier, 
dottedCanonicalSchema)
+
+    val tagged = microbatchOf(dottedTaggedSchema)(Row(1, "alice", 5L, null, 
Row(5L), false))
+    val affected = microbatchOf(dottedCanonicalSchema)()
+
+    processor.mergeRowsIntoTargetTable(tagged, affected, 
defaultTargetTableIdentifier)
+
+    checkAnswer(targetTable, Row(1, "alice", 5L, null, Row(5L)))
+  }
+
+  test("mergeRowsIntoTargetTable updates every non-key column of a matched 
row") {
+    createTable(defaultTargetIdent, defaultTargetTableIdentifier, 
wideCanonicalSchema)
+    microbatchOf(wideCanonicalSchema)(
+      Row(1, "old-name", "active", 10, 5L, null, Row(5L))
+    ).writeTo(defaultTargetTableIdentifier.quotedString).append()
+
+    // Every non-key column (name, status, score) differs from the existing 
row; the in-place
+    // update must refresh all of them, exercising the full non-key update 
assignment map.
+    val tagged = microbatchOf(wideTaggedSchema)(
+      Row(1, "new-name", "inactive", 42, 5L, 30L, Row(5L), false)
+    )
+    val affected = microbatchOf(wideCanonicalSchema)(
+      Row(1, "old-name", "active", 10, 5L, null, Row(5L))
+    )
+
+    processor.mergeRowsIntoTargetTable(tagged, affected, 
defaultTargetTableIdentifier)
+
+    checkAnswer(targetTable, Row(1, "new-name", "inactive", 42, 5L, 30L, 
Row(5L)))
+  }
+}
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 0e671721bacc..42b294248413 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
@@ -3040,4 +3040,314 @@ class Scd2BatchProcessorSuite extends QueryTest with 
SharedSparkSession {
       )
     )
   }
+
+  // =============== identifyAndTagAuxRows tests ===============
+
+  /** Select `id`, `value`, and the appended routing flag for compact 
assertions. */
+  private def withRouteFlag(df: DataFrame): DataFrame =
+    df.select(
+      F.col("id"),
+      F.col("value"),
+      F.col(Scd2BatchProcessor.startAtColName),
+      F.col(Scd2BatchProcessor.endAtColName),
+      F.col(AutoCdcReservedNames.cdcMetadataColName),
+      F.col(Scd2BatchProcessor.shouldRouteToAuxTableColName)
+    )
+
+  test("identifyAndTagAuxRows routes a tombstone to the auxiliary table") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // A tombstone (startAt == endAt == recordStartAt) is delete-encoded and 
must live in the
+    // aux table, never the target table.
+    val df = targetTableOf(userSchema)(
+      Row(1, "t", 5L, 5L, Row(5L))
+    )
+
+    checkAnswer(
+      df = withRouteFlag(processor.identifyAndTagAuxRows(df)),
+      expectedAnswer = Seq(
+        Row(1, "t", 5L, 5L, Row(5L), true)
+      )
+    )
+  }
+
+  test("identifyAndTagAuxRows does not route a decomposition tail to the 
auxiliary table") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // A decomposition tail (recordStartAt == null) is neither a tombstone nor 
an
+    // upsert-representing row, so it is tagged false. (Tails are 
promoted/dropped upstream of
+    // the merges; this pins that the router itself never claims them for the 
aux table.)
+    val df = targetTableOf(userSchema)(
+      Row(1, "tail", null, 10L, Row(null))
+    )
+
+    checkAnswer(
+      df = withRouteFlag(processor.identifyAndTagAuxRows(df)),
+      expectedAnswer = Seq(
+        Row(1, "tail", null, 10L, Row(null), false)
+      )
+    )
+  }
+
+  test("identifyAndTagAuxRows keeps a lone open upsert visible (no successor 
to coalesce with)") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // A single open upsert with no following row in its key window cannot be 
a hidden no-op
+    // continuation - it is the visible tail of its (size-1) run.
+    val df = targetTableOf(userSchema)(
+      Row(1, "v", 5L, null, Row(5L))
+    )
+
+    checkAnswer(
+      df = withRouteFlag(processor.identifyAndTagAuxRows(df)),
+      expectedAnswer = Seq(
+        Row(1, "v", 5L, null, Row(5L), false)
+      )
+    )
+  }
+
+  test("identifyAndTagAuxRows routes every no-op run member except the visible 
tail to the aux") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // A reconciled no-op run: three upserts agreeing on the tracked `value`, 
all sharing the
+    // run head's startAt=5 and open endAt. The first two coalesce into hidden 
aux rows; only
+    // the last (the run tail) stays visible in the target table.
+    val df = targetTableOf(userSchema)(
+      Row(1, "a", 5L, null, Row(5L)),
+      Row(1, "a", 5L, null, Row(10L)),
+      Row(1, "a", 5L, null, Row(15L))
+    )
+
+    checkAnswer(
+      df = withRouteFlag(processor.identifyAndTagAuxRows(df)),
+      expectedAnswer = Seq(
+        Row(1, "a", 5L, null, Row(5L), true),
+        Row(1, "a", 5L, null, Row(10L), true),
+        Row(1, "a", 5L, null, Row(15L), false)
+      )
+    )
+  }
+
+  test("identifyAndTagAuxRows keeps a row visible when the next row changes a 
tracked column") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // A real state change between the two rows (value a -> b) breaks the run, 
so the earlier
+    // closed upsert is a visible run tail rather than a hidden no-op 
continuation.
+    val df = targetTableOf(userSchema)(
+      Row(1, "a", 5L, 10L, Row(5L)),
+      Row(1, "b", 10L, null, Row(10L))
+    )
+
+    checkAnswer(
+      df = withRouteFlag(processor.identifyAndTagAuxRows(df)),
+      expectedAnswer = Seq(
+        Row(1, "a", 5L, 10L, Row(5L), false),
+        Row(1, "b", 10L, null, Row(10L), false)
+      )
+    )
+  }
+
+  test("identifyAndTagAuxRows keeps a row visible when it closes strictly 
before the next row") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // The first row closes at 8 but the next row only begins at 10, leaving a 
visible gap. Even
+    // though both rows agree on the tracked `value`, the earlier row cannot 
be hidden - dropping
+    // it would erase the gap from the visible timeline.
+    val df = targetTableOf(userSchema)(
+      Row(1, "a", 5L, 8L, Row(5L)),
+      Row(1, "a", 10L, null, Row(10L))
+    )
+
+    checkAnswer(
+      df = withRouteFlag(processor.identifyAndTagAuxRows(df)),
+      expectedAnswer = Seq(
+        Row(1, "a", 5L, 8L, Row(5L), false),
+        Row(1, "a", 10L, null, Row(10L), false)
+      )
+    )
+  }
+
+  test("identifyAndTagAuxRows coalesces consecutive upserts when the tracked 
set is empty") {
+    val processor = processorWithKeys(
+      keys = Seq("id"),
+      trackHistorySelection = Some(
+        ColumnSelection.ExcludeColumns(Seq(UnqualifiedColumnName("value")))
+      )
+    )
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // With `value` excluded the effective tracked set is empty, so 
consecutive gapless upserts
+    // are always tracked-equal: the earlier one is hidden even though the 
user data differs.
+    val df = targetTableOf(userSchema)(
+      Row(1, "a", 5L, null, Row(5L)),
+      Row(1, "b", 5L, null, Row(10L))
+    )
+
+    checkAnswer(
+      df = withRouteFlag(processor.identifyAndTagAuxRows(df)),
+      expectedAnswer = Seq(
+        Row(1, "a", 5L, null, Row(5L), true),
+        Row(1, "b", 5L, null, Row(10L), false)
+      )
+    )
+  }
+
+  test("identifyAndTagAuxRows evaluates runs independently per key") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // Two keys, each with its own no-op run. The per-key window must never 
let one key's rows
+    // influence another's: the last row of key 1 must stay visible (it is its 
run's tail, not a
+    // hidden continuation of key 2's run), and key 2's first row must be 
evaluated against key
+    // 2's successor only. A window that leaked across keys would mistag one 
of these boundary
+    // rows.
+    val df = targetTableOf(userSchema)(
+      Row(1, "a", 5L, null, Row(5L)),
+      Row(1, "a", 5L, null, Row(10L)),
+      Row(2, "b", 7L, null, Row(7L)),
+      Row(2, "b", 7L, null, Row(20L))
+    )
+
+    checkAnswer(
+      df = withRouteFlag(processor.identifyAndTagAuxRows(df)),
+      expectedAnswer = Seq(
+        Row(1, "a", 5L, null, Row(5L), true),
+        Row(1, "a", 5L, null, Row(10L), false),
+        Row(2, "b", 7L, null, Row(7L), true),
+        Row(2, "b", 7L, null, Row(20L), false)
+      )
+    )
+  }
+
+  test("identifyAndTagAuxRows quotes tracked columns whose names contain a 
dot") {
+    // 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)
+
+    // The dotted column is the tracked column. Two gapless upserts agreeing 
on it form a no-op
+    // run, so the earlier row is hidden. Without quoting, the 
tracked-equality comparison would
+    // parse `user.name` as a nested-field access (struct `user`, field 
`name`) and fail to
+    // resolve.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "alice", 5L, null, Row(10L))
+    )
+
+    val result = processor.identifyAndTagAuxRows(df)
+
+    checkAnswer(
+      df = result.select(
+        F.col("id"),
+        F.col("`user.name`"),
+        F.col(Scd2BatchProcessor.startAtColName),
+        F.col(Scd2BatchProcessor.endAtColName),
+        F.col(AutoCdcReservedNames.cdcMetadataColName),
+        F.col(Scd2BatchProcessor.shouldRouteToAuxTableColName)
+      ),
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, null, Row(5L), true),
+        Row(1, "alice", 5L, null, Row(10L), false)
+      )
+    )
+  }
+
+  // =============== antiJoinRowsByRecordStartAtPerKey tests ===============
+
+  test("antiJoinRowsByRecordStartAtPerKey returns only left rows with no (key, 
recordStartAt) " +
+    "match on the right") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    val left = targetTableOf(userSchema)(
+      Row(1, "L5", 5L, null, Row(5L)),
+      Row(1, "L9", 9L, null, Row(9L))
+    )
+    // Right matches the left row at recordStartAt=5 only; the left row at 9 
has no counterpart.
+    val right = targetTableOf(userSchema)(
+      Row(1, "R5", 5L, null, Row(5L))
+    )
+
+    checkAnswer(
+      df = processor.antiJoinRowsByRecordStartAtPerKey(left, right),
+      expectedAnswer = Seq(
+        Row(1, "L9", 9L, null, Row(9L))
+      )
+    )
+  }
+
+  test("antiJoinRowsByRecordStartAtPerKey matches per key: same recordStartAt 
under a different " +
+    "key is not a match") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    val left = targetTableOf(userSchema)(
+      Row(1, "a", 5L, null, Row(5L)),
+      Row(2, "b", 5L, null, Row(5L))
+    )
+    // Only key 1 at recordStartAt=5 matches; key 2 at the same recordStartAt 
must survive.
+    val right = targetTableOf(userSchema)(
+      Row(1, "r", 5L, null, Row(5L))
+    )
+
+    checkAnswer(
+      df = processor.antiJoinRowsByRecordStartAtPerKey(left, right),
+      expectedAnswer = Seq(
+        Row(2, "b", 5L, null, Row(5L))
+      )
+    )
+  }
+
+  test("antiJoinRowsByRecordStartAtPerKey matches null recordStartAt 
null-safely") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // Both sides carry a null recordStartAt for the same key. A null-safe 
equality treats the
+    // two as matching, so the left row is anti-joined away (an ordinary `=` 
would keep it).
+    val left = targetTableOf(userSchema)(
+      Row(1, "tailL", null, 10L, Row(null))
+    )
+    val right = targetTableOf(userSchema)(
+      Row(1, "tailR", null, 20L, Row(null))
+    )
+
+    assert(processor.antiJoinRowsByRecordStartAtPerKey(left, 
right).collect().isEmpty)
+  }
+
+  test("antiJoinRowsByRecordStartAtPerKey matches on the full composite key") {
+    val processor = processorWithKeys(Seq("id", "grp"))
+    val userSchema = new StructType()
+      .add("id", IntegerType)
+      .add("grp", StringType)
+      .add("value", StringType)
+
+    // Rows share id and recordStartAt but differ on the second key column 
`grp`. Only the exact
+    // composite-key match (1, "g") is removed; (1, "h") survives.
+    val left = targetTableOf(userSchema)(
+      Row(1, "g", "a", 5L, null, Row(5L)),
+      Row(1, "h", "b", 5L, null, Row(5L))
+    )
+    val right = targetTableOf(userSchema)(
+      Row(1, "g", "r", 5L, null, Row(5L))
+    )
+
+    checkAnswer(
+      df = processor.antiJoinRowsByRecordStartAtPerKey(left, right),
+      expectedAnswer = Seq(
+        Row(1, "h", "b", 5L, null, Row(5L))
+      )
+    )
+  }
 }


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

Reply via email to