[SPARK-57356][SDP] Implement SCD2 Batch Processor; Cleanup Delete Encoding Rows Post-Reconciliation#57365
[SPARK-57356][SDP] Implement SCD2 Batch Processor; Cleanup Delete Encoding Rows Post-Reconciliation#57365anew wants to merge 5 commits into
Conversation
| val metadata = reconciledDf.schema(c).metadata | ||
| F.when(isDecompositionTail, endAt).otherwise(startAt).as(c, metadata) | ||
| case c => | ||
| F.col(c) |
There was a problem hiding this comment.
That will mis-resolve user columns whose literal names contain dots or other identifier-sensitive characters, e.g. user.name, as nested-field paths. The rest of this class already handles these cases with QuotingUtils.quoteIdentifier, and there is existing test coverage for dotted tracked-history columns. This should use F.col(QuotingUtils.quoteIdentifier(c)) for pass-through columns.
There was a problem hiding this comment.
fixed and added a test.
| // `name`) and fail to resolve, so the select would throw. A decomposition tail (recordStartAt | ||
| // and startAt null, endAt=20) is promoted to a tombstone at 20 while the dotted user column | ||
| // survives verbatim. | ||
| val df = targetTableOf(userSchema)( |
There was a problem hiding this comment.
- Without the fix in this last commit, it fails with
[UNRESOLVED_COLUMN.WITH_SUGGESTION] A column ... with name `user.name` cannot be resolved
- With the fix, the full suite passes
jose-torres
left a comment
There was a problem hiding this comment.
As with the prior PR, the behavior here makes sense on its own terms but raises questions we'll want to go back and review on a full-algorithm basis. Please do clarify the one example, and I'll wait a bit for comments from @szehon-ho before merging.
| * [[endAtColName]] equals its own sequence: the preceding upsert already encodes the delete | ||
| * boundary, so the standalone delete-encoded row should no longer be routed to aux. | ||
| * | ||
| * For example, an open upsert `[startAt=10, endAt=null)` followed by a tombstone at `15` |
There was a problem hiding this comment.
The way this is stated is confusing to me. Why does the history record identity follow the prefix of the range when closing an active row but the suffix when closing an inactive row?
There was a problem hiding this comment.
Good point — the asymmetry was only in the wording, not the logic. Rewrote the doc to state the single rule both cases share: a delete-encoded row is redundant when the immediately preceding reconciled upsert's endAt equals the row's own boundary (its startAt/identity is irrelevant to the check). I also spelled out where the [null, 20) decomposition tail comes from — the open-head + tail split produced when a closed row is bisected by an out-of-order event.
| isDeleteEncodedRow && (F.col(Scd2BatchProcessor.previousEndAtColName) <=> endAt) | ||
| ) | ||
|
|
||
| withWindowCols |
There was a problem hiding this comment.
I suppose the implementation of this method doesn't end up depending on the row identity confusion above, but I still think it's worth clearing up.
There was a problem hiding this comment.
I suppose. this still relates to the previous comment? If so, I hope the updated doc makes it clear
| } | ||
|
|
||
| /** | ||
| * Convert surviving decomposition tails into tombstones. |
There was a problem hiding this comment.
Another thing to handle in a post-hoc pass after implementation: we should make sure that these two are actually handled in a different enough way to require two different representations. It kinda sounds to me like a decomposition tail is just the within-batch version of a tombstone.
There was a problem hiding this comment.
Agreed this is worth a look on the full-algorithm pass. They're handled identically here (both are just delete-boundary encodings), and a decomposition tail is essentially the within-batch analog of a tombstone — the main distinction today is provenance (a tail is synthesized during bisection and carries a null recordStartAt, a tombstone comes from an actual delete event). Whether they can be collapsed into one representation is a good question to settle when we review the algorithm end-to-end; but I think it s outside of this PR's scope.
| val metadata = reconciledDf.schema(c).metadata | ||
| F.when(isDecompositionTail, endAt).otherwise(startAt).as(c, metadata) | ||
| case c => | ||
| F.col(QuotingUtils.quoteIdentifier(c)) |
There was a problem hiding this comment.
I guess we only need the quote here because we know the exact names of the other cases and they don't contain special characters?
There was a problem hiding this comment.
Exactly. The other case branches match framework columns. I added a comment to clarify that
| ) | ||
| .withColumn( | ||
| Scd2BatchProcessor.isRedundantDeleteEncodingColName, | ||
| isDeleteEncodedRow && (F.col(Scd2BatchProcessor.previousEndAtColName) <=> endAt) |
There was a problem hiding this comment.
Could we defensively verify that the preceding row is an upsert? Adjacent delete-encoded rows with the same boundary should not occur because upstream cleanup prevents them, but checking here would make this method enforce its documented invariant directly. For example:
val previous = row.lagBy(1, orderChronologicallyPerKeyWindow)
val isRedundantDeleteEncoding =
isDeleteEncodedRow &&
// Defensive: the predecessor should always be an upsert because upstream cleanup prevents
// adjacent delete-encoded rows with the same boundary.
RowClassifier.isUpsertRepresentingRow(previous) &&
(previous.endAt <=> endAt)There was a problem hiding this comment.
Good idea — done. Adapted it to the existing window setup: I build the lagged interval via row.lagBy(1, orderChronologicallyPerKeyWindow) and gate on RowClassifier.isUpsertRepresentingRow(previous) before the endAt comparison, which also let me drop the separate previousEndAt temp column. Added a test with two adjacent tombstones on the same boundary — the second now correctly survives (its predecessor isn't an upsert), which the old check would have dropped.
(cherry picked from commit 75ce170)
The default pass-through branch used F.col(c), which parses column names containing dots (e.g. `user.name`) as nested-field paths and fails to resolve them. Quote the name with QuotingUtils.quoteIdentifier, matching how the rest of the class handles user column names, and add a test with a dotted column.
- Rewrite the dropLeftoverDeletesPostReconciliation scaladoc so both examples are stated as the same rule (a delete-encoded row is redundant when the immediately preceding reconciled upsert's endAt equals the row's boundary), and spell out where the [null, 20) decomposition tail in the second example comes from (the head + tail split produced when a closed row is bisected). - Add a comment in promoteDecompositionTailsToTombstones explaining that only the default (user-column) case needs quoting, since the other cases match framework columns with known, identifier-safe names.
dropLeftoverDeletesPostReconciliation only checked that the immediately preceding row's endAt equals the delete-encoded row's boundary, but the method's documented rule is that the preceding *upsert* must close on that boundary. Add RowClassifier.isUpsertRepresentingRow(previous) to the redundancy predicate so the method enforces its invariant directly, rather than dropping a delete-encoded row that merely abuts another delete-encoded row on the same boundary. Build the lagged interval via row.lagBy(1, ...) and read previous.endAt from it, which removes the separate previousEndAtColName temp column. Add a test covering two adjacent tombstones on the same boundary: the first is redundant and drops, the second survives because its predecessor is not an upsert. Co-authored-by: Isaac
e7480d8 to
0803968
Compare
|
had to force-push this in order to rebase on latest master (and fix the broken CI caused by the previous master). |
…oding Rows Post-Reconciliation
### What changes were proposed in this pull request?
This PR adds two post-reconciliation cleanup stages to the SCD2 batch processor
(`Scd2BatchProcessor`), used by Declarative Pipelines' AUTO CDC / SCD Type 2 processing.
Both operate on the output of `reconcileStartAndEndAt`, which is where an affected key's
rows get their final `startAt` / `endAt` intervals.
- **`dropLeftoverDeletesPostReconciliation`** — drops delete-encoded rows (tombstones and
decomposition tails) that became redundant after reconciliation. A delete-encoded row is
redundant when the immediately preceding row's reconciled `endAt` already equals its own
sequence, i.e. the preceding upsert already encodes the delete boundary. Redundancy is
computed per key using a `lag` over the chronological per-key window (`__previous_end_at` /
`__is_redundant_delete_encoding` temporary columns, dropped before returning).
- e.g. an open upsert `[10, null)` followed by a tombstone at `15` reconciles to a closed
upsert `[10, 15)`, making the tombstone redundant.
- e.g. an existing closed `[10, 20)` bisected by an event at `15` reconciles the event to
`[15, 20)`, making the `[null, 20)` decomposition tail redundant.
- **`promoteDecompositionTailsToTombstones`** — converts decomposition tails that *survive*
the deletion cleanup above into tombstones. A surviving tail is an unmatched delete
boundary; rewriting its `recordStartAt` and `startAt` to the tail's end sequence lets
downstream auxiliary-table handling preserve it as a tombstone. Column metadata and the
input schema are preserved.
Both methods are `private[autocdc]` building blocks with unit coverage; they are not yet
wired into the top-level processing flow (a follow-up composes them into the pipeline).
### Why are the changes needed?
After start/end reconciliation, some delete-encoded rows carry information that the
reconciled upserts already express, and some decomposition tails remain as standalone delete
boundaries. Without this cleanup, redundant tombstones/tails would be routed to the auxiliary
table needlessly, and unmatched tails would not be represented in the form downstream aux
handling expects. These two stages normalize the reconciled output so that each key's history
is encoded once and correctly.
### Does this PR introduce _any_ user-facing change?
No, SCD2 is an unreleased feature.
### How was this patch tested?
Added 13 unit tests to `Scd2BatchProcessorSuite` covering the two new methods (drop-vs-keep
for tombstones and decomposition tails, no-window-predecessor cases, upserts never dropped,
per-key independence, combined redundant drops; and tail-to-tombstone rewriting, pass-through
of tombstones/upserts, user-column and schema/metadata preservation, per-key promotion).
### Was this patch authored or co-authored using generative AI tooling?
Co-authored with Claude Opus 4.7 and 4.8
Closes #57365 from anew/SPARK-57356-post-reconciliation-cleanup.
Lead-authored-by: Andreas Neumann <anew@apache.org>
Co-authored-by: Anish Mahto <anish.mahto99@gmail.com>
Signed-off-by: Szehon Ho <szehon.apache@gmail.com>
(cherry picked from commit e350bb0)
Signed-off-by: Szehon Ho <szehon.apache@gmail.com>
What changes were proposed in this pull request?
This PR adds two post-reconciliation cleanup stages to the SCD2 batch processor
(
Scd2BatchProcessor), used by Declarative Pipelines' AUTO CDC / SCD Type 2 processing.Both operate on the output of
reconcileStartAndEndAt, which is where an affected key'srows get their final
startAt/endAtintervals.dropLeftoverDeletesPostReconciliation— drops delete-encoded rows (tombstones anddecomposition tails) that became redundant after reconciliation. A delete-encoded row is
redundant when the immediately preceding row's reconciled
endAtalready equals its ownsequence, i.e. the preceding upsert already encodes the delete boundary. Redundancy is
computed per key using a
lagover the chronological per-key window (__previous_end_at/__is_redundant_delete_encodingtemporary columns, dropped before returning).[10, null)followed by a tombstone at15reconciles to a closedupsert
[10, 15), making the tombstone redundant.[10, 20)bisected by an event at15reconciles the event to[15, 20), making the[null, 20)decomposition tail redundant.promoteDecompositionTailsToTombstones— converts decomposition tails that survivethe deletion cleanup above into tombstones. A surviving tail is an unmatched delete
boundary; rewriting its
recordStartAtandstartAtto the tail's end sequence letsdownstream auxiliary-table handling preserve it as a tombstone. Column metadata and the
input schema are preserved.
Both methods are
private[autocdc]building blocks with unit coverage; they are not yetwired into the top-level processing flow (a follow-up composes them into the pipeline).
Why are the changes needed?
After start/end reconciliation, some delete-encoded rows carry information that the
reconciled upserts already express, and some decomposition tails remain as standalone delete
boundaries. Without this cleanup, redundant tombstones/tails would be routed to the auxiliary
table needlessly, and unmatched tails would not be represented in the form downstream aux
handling expects. These two stages normalize the reconciled output so that each key's history
is encoded once and correctly.
Does this PR introduce any user-facing change?
No, SCD2 is an unreleased feature.
How was this patch tested?
Added 13 unit tests to
Scd2BatchProcessorSuitecovering the two new methods (drop-vs-keepfor tombstones and decomposition tails, no-window-predecessor cases, upserts never dropped,
per-key independence, combined redundant drops; and tail-to-tombstone rewriting, pass-through
of tombstones/upserts, user-column and schema/metadata preservation, per-key promotion).
Was this patch authored or co-authored using generative AI tooling?
Co-authored with Claude Opus 4.7 and 4.8