szehon-ho commented on code in PR #58209:
URL: https://github.com/apache/spark/pull/58209#discussion_r3833439691


##########
sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessorSuite.scala:
##########
@@ -1214,19 +1231,60 @@ class Scd2BatchProcessorSuite extends QueryTest with 
SharedSparkSession {
     )
   }
 
+  test("affected sequence cutoff derives from the target table") {
+    val processor = processorWithKeys(Seq("id"))
+    val keySchema = new StructType().add("id", IntegerType)
+    val userSchema = keySchema.add("value", StringType)
+
+    val aux = auxTableOf(userSchema)(Row(1, "aux", 40L, null, Row(40L), null))
+    val target = targetTableOf(userSchema)(Row(1, "target", 42L, null, 
Row(42L)))
+    val minSeq = minSeqOf(keySchema)(Row(1, 50L))
+
+    // The target's row at 42 is the cutoff, so the auxiliary row at 40 falls 
below it.
+    checkAnswer(
+      df = findAffectedTargetRows(processor, target = target, aux = aux, 
minSeq = minSeq),
+      expectedAnswer = Seq(Row(1, "target", 42L, null, Row(42L)))
+    )
+    checkAnswer(
+      df = findAffectedAuxRows(processor, aux = aux, target = target, minSeq = 
minSeq),
+      expectedAnswer = Seq.empty[Row]
+    )
+  }
+
+  test("affected sequence cutoff derives from the auxiliary table") {
+    val processor = processorWithKeys(Seq("id"))
+    val keySchema = new StructType().add("id", IntegerType)
+    val userSchema = keySchema.add("value", StringType)
+
+    val aux = auxTableOf(userSchema)(Row(1, "aux", 42L, null, Row(42L), null))
+    val target = targetTableOf(userSchema)(Row(1, "target", 40L, null, 
Row(40L)))
+    val minSeq = minSeqOf(keySchema)(Row(1, 50L))

Review Comment:
   Could this fixture be rebuilt from a state the pipeline can actually reach?
   
   An aux row with `startAt == recordStartAt` and `endAt = null` is a hidden 
run head, which by definition has its visible tail later - so the target row 
for the same key would have to be at 42 or beyond, not at 40. As written the 
state cannot occur in production.
   
   What the test then records as correct is dropping an *active* target row 
(`endAt = null`) from the affected set. That is harmless on an impossible 
input, but in any reachable state nothing would close that row, and the batch 
would leave two open intervals for the key. A reader who later hits a real case 
of this has a green test telling them the behavior is intended.
   
   An aux tombstone at 42 with the target row closed at 42 is reachable and 
still exercises an aux-derived cutoff.



##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessor.scala:
##########
@@ -290,115 +277,121 @@ case class Scd2BatchProcessor(
         auxTableDeletedByBatchIdCol.isNull ||
           auxTableDeletedByBatchIdCol === F.lit(batchId)
       )
-      // Drop the aux-only idempotency column so the output schema matches 
target-table rows
-      // and preprocessed-microbatch rows (which share the same canonical SCD2 
row schema).
       .drop(Scd2BatchProcessor.deletedByBatchIdColName)
+  }
 
-    val perKeyMinimumSequenceInMicrobatchCol = 
F.col(Scd2BatchProcessor.minSequenceColName)
+  /**
+   * Project a table of canonical SCD2 rows down to `[key1, ... keyN, 
effectiveRecordStartAt]`.
+   */
+  private def projectEffectiveRecordStartAtPerRow(rowsDf: DataFrame): 
DataFrame =
+    rowsDf.select(
+      keysQuoted.map(F.col) :+
+        Scd2BatchProcessor.canonicalRowIntervalColumns.effectiveRecordStartAt
+          .as(Scd2BatchProcessor.effectiveRecordStartAtColName): _*
+    )
 
-    // Per key, identify the sequence value associated with the anchor row in 
the aux table.
-    //
-    // The anchor row is the aux row with the largest 
[[recordStartAtFieldName]] strictly less
-    // than the min sequence in the incoming microbatch for that key. The 
reconciler needs this
-    // "left context" in two cases:
-    //   (1) Incoming no-op upsert: without the anchor, it would look like a 
new run head, when in
-    //       reality it's a part of an existing no-op run/head.
-    //   (2) Incoming state-changing upsert that bisects two aux no-ops: the 
anchor surfaces
-    //       the before-half so both halves can be promoted to target. (The 
after-half is
-    //       picked up by the >= minSeq branch.)
-    //
-    // Because no-op upserts are stored only in the aux table, the anchor 
concept only exists when
-    // pulling in rows from the aux table, and is not relevant for the target 
table.
-    //
-    // Keys with no aux row strictly before the min sequence have no anchor; 
their affected set
-    // reduces to "all aux rows at or after the min sequence."
-    //
-    // The shape of this DataFrame is: [key1, key2, ... keyN, anchorSequence]
-    val perKeyAnchorSequenceDf = reducedAuxiliaryTableDf
+  /**
+   * Per key; calculate the earliest point in time (sequence) at or after 
which all existing rows
+   * across the auxiliary and target tables may be affected by the microbatch, 
and therefore should
+   * be pulled in for reconciliation. The row sitting exactly at the cutoff is 
itself included.
+   *
+   * Returns a dataframe with one row per distinct key in 
[[perKeyMinimumSequenceInMicrobatchDf]],
+   * with the key columns and the calculated [[affectedSequenceCutoffColName]] 
column.
+   */
+  private[autocdc] def computePerKeyAffectedSequenceCutoff(

Review Comment:
   Worth documenting the invariant that makes the target-side narrowing safe 
here.
   
   The old `findAffectedRowsFromTargetTable` included the active row 
unconditionally via `endAt.isNull`, with a paragraph explaining why; selection 
is now purely on `effectiveRecordStartAt >= cutoff` and that paragraph is gone. 
The narrowing is fine, but only because no aux row ever sits inside a target 
interval above that row's `recordStartAt` (a hidden no-op always precedes its 
run's visible tail, and a delete inside a target interval bisects it rather 
than leaving a tombstone there), so a target row covering `minSeq` always sets 
the cutoff - the active row being the special case of that.
   
   That chain is currently written nowhere. If a future change ever leaves an 
aux row above the active target row, the active row is silently dropped from 
the affected set and the batch opens a second interval for the key, with 
nothing to catch it. Related to my note on `"affected sequence cutoff derives 
from the auxiliary table"`.



##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessor.scala:
##########
@@ -94,17 +94,22 @@ case class Scd2BatchProcessor(
         // tail can detect its own redundancy via LEAD(1): if the next row is 
a non-tail at
         // the same instant, the synthetic close the tail encodes is already 
represented by
         // that event and the tail is dropped downstream.
-        //
-        // Any tiebreaking beyond this rule only meaningfully fires when the 
user's source
-        // has emitted two or more events at the same sequence, violating the 
uniqueness
-        // contract above. Behavior in that case is publicly undefined and the 
remaining
-        // tiebreaker clauses exist as a best-effort to keep retries and 
replays deterministic.
         orderDecompositionTailsFirst,
         // Upsert-representing rows sort before tombstones because rows detect 
if they are being
         // bisected by LEAD(1). This allows upserts to match against 
same-sequence deletes, an
         // arbitrary but deterministic convention. When this happens, the 
delete event will survive
         // and persist as a tombstone in the auxiliary table.
-        orderUpsertRepresentingRowsFirst
+        orderUpsertRepresentingRowsFirst,
+        // Amongst upsert-representing rows, there's one valid case where rows 
are still tied, even
+        // if the user's change feed source did not emit duplicate sequences: 
the auxiliary merge
+        // commits before the target merge, which re-reads that table, so a 
row this batch wrote to
+        // the auxiliary table re-enters the window beside the copy the 
microbatch or the target
+        // table still holds. The copies differ only in the boundaries each 
one recorded, so
+        // descending order keeps the earliest of them - 
dropRedundantRowsPostDecomposition drops a
+        // tie's leading row - and nulls sort first so a copy that recorded no 
boundary never
+        // displaces one that did.
+        startAtCol.desc_nulls_first,

Review Comment:
   The "nulls sort first so a copy that recorded no boundary never displaces 
one that did" rationale applies to `endAt` only. A null `startAt` occurs only 
on decomposition tails, which `orderDecompositionTailsFirst` has already 
separated by the time this key is consulted, so the null handling on this key 
can never decide a tie. Might be worth splitting the comment so each key 
carries its own reason.



##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessor.scala:
##########
@@ -1492,12 +1492,35 @@ object Scd2BatchProcessor {
     s"${AutoCdcReservedNames.prefix}is_redundant_delete_encoding"
 
   /**
-   * 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
-   * amongst all rows for a key (or risk undefined behavior), this sequence 
value uniquely
-   * identifies an exact row in the aux.
+   * Name of the temporary column carrying a row's 
[[Scd2IntervalColumns.effectiveRecordStartAt]]
+   * in the narrow, key-plus-ordering-metadata union of the auxiliary and 
target tables that
+   * [[Scd2BatchProcessor.computePerKeyAffectedSequenceCutoff]] aggregates 
over.
+   *
+   * Temporary in that the column has no observable side effect or persistence 
across microbatches.
+   */
+  private val effectiveRecordStartAtColName: String =
+    s"${AutoCdcReservedNames.prefix}effective_record_start_at"
+
+  /**
+   * Name of the temporary column holding, per key, the largest effective 
ordering position
+   * strictly below the key's minimum microbatch sequence, across the 
auxiliary AND target tables
+   * jointly. Null for a key with no such row, in which case the affected 
sequence cutoff falls
+   * back to the microbatch minimum.
+   *
+   * Temporary in that the column has no observable side effect or persistence 
across microbatches.
+   */
+  private val latestSequenceBeforeMicrobatchColName: String =
+    s"${AutoCdcReservedNames.prefix}latest_sequence_before_microbatch"
+
+  /**
+   * Name of the temporary column holding the single per-key cutoff on 
effective ordering
+   * position that gates affected-row selection from both the auxiliary and 
the target table, as
+   * computed by [[Scd2BatchProcessor.computePerKeyAffectedSequenceCutoff]].
+   *
+   * Temporary in that the column has no observable side effect or persistence 
across microbatches.
    */
-  private val anchorSequenceColName: String = 
s"${AutoCdcReservedNames.prefix}anchor_sequence"
+  private[autocdc] val affectedSequenceCutoffColName: String =

Review Comment:
   Can this be plain `private`? It has no use outside the object - the 
`anchorSequenceColName` it replaces was `private`, as are 
`effectiveRecordStartAtColName` and `latestSequenceBeforeMicrobatchColName` 
added alongside it.



##########
sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/Scd2ForeachBatchHandlerSuite.scala:
##########
@@ -715,6 +715,71 @@ class Scd2ForeachBatchHandlerSuite
     checkAnswer(auxTable, auxRow(1, null, 20L, 20L, 20L, 2L))
   }
 
+  test("the auxiliary merge's own writes do not duplicate the rows the target 
merge reads") {

Review Comment:
   Nice test. One gap: both copies here sit at `startAt = 5`, so only the 
`endAt` key is exercised. The `startAt` key takes precedence and is 
load-bearing in the case where the microbatch moves the run head, so the aux 
copy carries a different run start than the target copy.
   
   That case does resolve correctly as far as I can tell - a tie makes 
`nextRecordStartAt == recordStartAt < endAt`, so a closed copy sorting first 
always decomposes and its tail preserves the boundary, while a closed copy 
sorting last survives intact - but that is a load-bearing argument with no test 
behind it. Would be good to cover.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to