[ 
https://issues.apache.org/jira/browse/SPARK-58452?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Andreas Neumann updated SPARK-58452:
------------------------------------
    Description: 
h2. Goal

Support schema evolution for SCD Type 2 AutoCDC targets, matching the 
flexibility SCD Type 1 already has. Two independent capabilities are in scope, 
and they are very different in difficulty:

# *Column-schema evolution* -- the set of user data columns the flow emits 
changes across runs (a column is dropped from the input, a nested field is 
dropped, or the column/EXCEPT selection narrows), WITHOUT changing the 
effective tracked-history column set. Tracked as SPARK-58418.
# *Tracked-column-set change* -- the set of columns that define an SCD2 "run" 
changes across runs (adding or removing a tracked column, whether explicitly 
via {{TRACK HISTORY ON}} or implicitly through the default / {{* EXCEPT}} 
resolution following an input-schema change).

All statements below were verified empirically against master by prototyping 
and observing the resulting target rows (scratch suites, not committed).

h2. Background: how the tracked set is used

In {{Scd2BatchProcessor.reconcileStartAndEndAt}}, two chronologically-adjacent 
upsert events for the same key are coalesced into a single record iff they are 
gap-free and equal on *every tracked column* ({{isNoOpUpsertContinuation}}). A 
change in a tracked column opens a new record; a change in a non-tracked column 
is folded into the current record. So the tracked set is the sole determinant 
of where record boundaries fall.

The reconciliation window is bounded per key: 
{{findAffectedRowsFrom{Target,Auxiliary}Table}} pull in only rows at or after 
{{minSeq}} (the minimum sequence of the *current microbatch* for that key), 
plus a single aux "anchor" row. History below that point is normally never 
revisited -- unless a late event drags it into the window.

h2. Part 1 -- Column-schema evolution (SPARK-58418): small, ship first

*Root cause of the current failure:* 
{{Scd2ForeachBatchHandler.reconcileMicrobatch}} unions the microbatch with the 
affected target/aux rows via {{unionByName}} with no {{allowMissingColumns}}. A 
microbatch narrower than the (already-evolved, wider) target then fails -- 
{{NUM_COLUMNS_MISMATCH}} for a dropped top-level column, 
{{INCOMPATIBLE_COLUMN_TYPE}} for a dropped nested struct/array field.

*Fix:* pass {{allowMissingColumns = true}} to the two {{unionByName}} calls. 
Verified this fixes both the top-level and nested cases (the flag recurses into 
structs/arrays; map types remain unsupported), preserves historical values (the 
closing UPDATE draws from the target read, not the null-padded microbatch), and 
is safe through the aux round-trip. No changes needed in the merges, the aux 
schema (derived from the evolved target and evolved with 
{{mergeWithExistingSchema = true}}), or the reconciliation transforms.

This half is a one-line production change plus tests, and resolves SPARK-58418. 
It covers only column evolution that leaves the tracked set unchanged; PR A 
must NOT make the tracked set evolve (that is Part 2 and requires the guard 
below, or it corrupts history -- see Findings).

h2. Part 2 -- Tracked-column-set change: allow only via full refresh

Allowing the tracked set to change and letting the stream continue 
incrementally does *not* preserve correctness. Verified:

* *Finding A (in-order):* changing the tracked set produces a temporal 
patchwork -- records below the change keep the old segmentation, records from 
the change onward use the new one.
* *Finding B (late events):* a late-arriving event drops {{minSeq}} backward, 
dragging pre-change history into the affected window, where it is re-diced 
under the current tracked set. Narrowing then silently *destroys* pre-change 
records (they coalesce); widening *fabricates* records (retroactive splits), 
both with sequences below the change point. Reproduced in both directions, 
including the implicit case where a default-tracked column leaves the input 
schema and the effective set evolves to a subset.
* *Finding C (blast radius):* the corruption is confined to keys present in the 
triggering microbatch (the affected-row scans inner-join on the key against the 
per-key {{minSeq}}), and reaches back to that key's batch-min. A key that stays 
quiet keeps its old segmentation.

Because incremental tracked-set changes are unsafe under the bounded-window 
design, the chosen approach is to *disallow* them incrementally and require a 
full refresh, which starts from a clean target and aux table so there is no 
pre-change history to corrupt. Two rules:

# *The effective tracked set is derived from the column selection.* Default = 
all selected non-key/non-framework columns; {{* EXCEPT (...)}} = selected minus 
the exclusions; explicit {{TRACK HISTORY ON (...)}} = the enumerated list. So 
dropping a column from the selection (default / {{* EXCEPT}} modes) correctly 
removes it from the tracked set -- it is not kept tracked just because it 
lingers in the (sticky) target schema.
# *A guard requires a full refresh whenever the effective tracked set changes*, 
however it was configured. This turns claims 2, 3, and 4 -- and claim 1 when 
the removed column was tracked -- into a clear, actionable 
{{AUTOCDC_INVALID_STATE.TRACK_HISTORY_DRIFT}} error (remedy: full refresh) 
instead of a crash or silent corruption.

This needs no change watermark and no changes to {{reconcileStartAndEndAt}}; it 
is a validation-plus-computation change.

h3. Reuse of SPARK-58391

SPARK-58391 (open PR apache/spark#57625) already implements exactly this guard: 
it records the tracked set as the auxiliary-table property 
{{pipelines.autocdc.trackHistoryColumnNames}} and rejects a changed set with 
{{AUTOCDC_INVALID_STATE.TRACK_HISTORY_DRIFT}}. Part 2 reuses it directly, with 
*one required change*:

* SPARK-58391 currently computes the recorded/compared tracked set from the 
*evolved target schema* ({{buildScd2AuxiliaryTableSpecFor}} passes 
{{targetTableSchema}} to {{Scd2BatchProcessor.computeTrackedHistoryColumns}}). 
Because the target schema keeps columns that have been dropped from the input, 
this would fail to detect the *implicit* evolution cases (default / {{* 
EXCEPT}} tracking where an input column disappears): the recorded and current 
sets would both still contain the dropped column, no drift is detected, and the 
run proceeds into the corruption of Finding B.
* Rule (1) requires deriving the tracked set from the *column-selection-based 
(user-selected source) schema* instead -- the same {{userSelectedSchema}} that 
the construction-time guard 
{{Flow.requireTrackHistoryColumnsResolvableInSelectedSchema}} already uses -- 
at both the recording site and the comparison, so implicit tracked-set changes 
are detected and correctly demand a full refresh.

So the direction for apache/spark#57625 is to land (or be amended) with the 
selection-derived tracked set, at which point it directly becomes the Part 2 
guard -- avoiding an add-then-relax churn.

h2. Claim-by-claim outcome under this design

* *Claim 1 (remove a column from the input schema):* incremental via the Part 1 
union fix if the column was not tracked; full refresh if it was tracked 
(default / {{* EXCEPT}}).
* *Claim 2 (add a new column as a tracking column):* full refresh.
* *Claim 3 (remove a tracking column from the column selection):* full refresh 
(default / {{* EXCEPT}}); already rejected at construction if the column is 
explicitly tracked but dropped from the selection.
* *Claim 4 (remove a column from the tracked set):* full refresh.

Only column-schema evolution that leaves the tracked set unchanged stays 
incremental; every tracked-set change surfaces a clear full-refresh error 
rather than the current crash/corruption.

h2. Proposed sequencing

# *PR A -- SPARK-58418:* the {{allowMissingColumns}} union fix. Independent, 
small, ship first. Must not evolve the tracked set.
# *PR B -- this ticket:* rules (1)+(2), built on apache/spark#57625 
(SPARK-58391) with the selection-derived-tracked-set change and tests for the 
implicit evolution cases. No watermark.


  was:
h2. Goal

Support schema evolution for SCD Type 2 AutoCDC targets, matching the 
flexibility SCD Type 1 already has. Two independent capabilities are in scope:

# *Column-schema evolution* -- the set of user data columns the flow emits 
changes across runs (a column is dropped from the input, a nested field is 
dropped, or the column/EXCEPT selection narrows).
# *Tracked-column-set change* -- the set of columns that define an SCD2 "run" 
changes across runs (adding or removing a tracked column, whether explicitly 
via {{TRACK HISTORY ON}} or implicitly through the default/{{EXCEPT}} 
resolution).

These are very different in difficulty and are analyzed separately below. All 
statements were verified empirically against master by prototyping and 
observing the resulting target rows.

h2. Background: how the tracked set is used

In {{Scd2BatchProcessor.reconcileStartAndEndAt}}, two chronologically-adjacent 
upsert events for the same key are coalesced into a single record iff they are 
gap-free and equal on *every tracked column* ({{isNoOpUpsertContinuation}}). A 
change in a tracked column opens a new record; a change in a non-tracked column 
is folded into the current record. So the tracked set is the sole determinant 
of where record boundaries fall.

The reconciliation window is bounded per key: 
{{findAffectedRowsFrom{Target,Auxiliary}Table}} pull in only rows at or after 
{{minSeq}} (the minimum sequence of the *current microbatch* for that key), 
plus a single aux "anchor" row for left context. History below that point is 
normally never revisited.

h2. Part 1 -- Column-schema evolution (JIRA claims 1 and 3): small, clean

*Root cause of the current failure:* 
{{Scd2ForeachBatchHandler.reconcileMicrobatch}} unions the microbatch with the 
affected target/aux rows via {{unionByName}} with no {{allowMissingColumns}}. A 
microbatch narrower than the (already-evolved, wider) target then fails -- 
{{NUM_COLUMNS_MISMATCH}} for a dropped top-level column, 
{{INCOMPATIBLE_COLUMN_TYPE}} for a dropped nested struct/array field. This is 
the bug tracked as SPARK-58418. SCD1 is unaffected because it never unions the 
microbatch with target rows; it MERGEs, which tolerates a narrower source.

*Fix:* pass {{allowMissingColumns = true}} to the two {{unionByName}} calls. 
Verified this:
* fixes both the top-level and nested cases (the flag recurses into structs and 
arrays; map types remain unsupported, a documentable limitation);
* *preserves historical values* -- the closing UPDATE of an existing record 
draws its values from the target read, not the null-padded microbatch, so 
records written before the drop keep their real values; only records opened 
after the drop carry NULL for the no-longer-emitted column (correct prospective 
semantics);
* is safe through the aux round-trip (a no-op run pulled from the aux table as 
an anchor is not corrupted).

No changes are needed in the two merges (they build column lists dynamically), 
the auxiliary-table schema (derived from the evolved target each run and 
evolved with {{mergeWithExistingSchema = true}}), or the reconciliation 
transforms (all name-driven). This half is essentially a one-line production 
change plus tests, and it also resolves SPARK-58418.

h2. Part 2 -- Tracked-column-set change (JIRA claims 2 and 4): NOT safe under 
the bounded-window design

Allowing the tracked set to change and letting the stream continue does *not* 
preserve correctness, because the per-key bounded reconciliation window 
re-segments whatever history it pulls in under the *currently configured* 
tracked set. Three findings, all verified:

h3. Finding A -- Prospective tail rewrite (in-order events)

With only in-order events, {{minSeq}} is always at or above everything already 
written, so the affected window only touches the tail. Changing the tracked set 
then produces a table that is a *temporal patchwork*: records below the change 
keep the old segmentation, records from the change onward use the new one. 
Boundary placement is inconsistent, though stored values are not corrupted. 
This is the "prospective" behavior one might naively accept.

h3. Finding B -- Late events reach backward and rewrite pre-change history

A late-arriving event drops {{minSeq}} *backwards*, dragging pre-change history 
into the affected window, where it is re-diced under the current tracked set. 
Verified both directions:
* *Narrowing* (track color -> track a constant column), history {{[c1 10-20][c2 
20-30][c1 30-null]}}, late event at seq 15: the three records collapse into a 
single {{[10-null]}} record. *Two historical records are silently destroyed*, 
and the survivor spans an interval no configuration ever produced.
* *Widening* (track a constant column -> track color), history {{[c3 10-null]}} 
(one coalesced record), late event at seq 15: the single record is 
retroactively shredded into four color-delimited records at sequences 10/20/30 
-- all *before* the change. *Spurious historical records are fabricated*, 
carrying sequences below the change point but the new segmentation.

So the answer to "can this drop or fabricate history records with sequences 
before the change?" is *yes to both*. The rewrite window for a touched key is 
{{[minSeq(key), infinity)}}, and how far back it reaches is determined by how 
late the latest late event happens to be -- non-deterministic from the user's 
perspective. This means the "prospective, old history left as-is" model of 
Finding A is not actually achievable by relaxing validation alone.

h3. Finding C -- Blast radius is confined to keys present in the triggering 
microbatch

Verified that a key with no event in the microbatch that carries the changed 
tracked set is left *byte-for-byte unchanged*, even though the configuration 
change is global to the flow. This is structural: 
{{findAffectedRowsFrom{Target,Auxiliary}Table}} inner-join on the key columns 
against the per-key {{minSeq}} dataframe, which has a row only for keys present 
in the microbatch. So the re-dicing is per-key and event-triggered: a key's 
history is re-segmented only when that key next receives an event after the 
change; a quiet key keeps its old-set segmentation indefinitely.

h3. Combined characterization

Corruption from a tracked-set change is localized to {{(touched key, sequences 
>= that key's batch-min)}}. Findings B and C are the same "affected window per 
key" viewed along the sequence axis and the key axis respectively.

h2. Design direction for Part 2 -- change watermark

Because both the late-event reach (B) and the per-key re-dicing (C) are bounded 
on the *sequence* axis, a single mechanism addresses them: record the sequence 
(or batch id) at which the tracked set last changed -- a *change watermark* -- 
as auxiliary-table state, and make reconciliation refuse to re-segment records 
whose {{recordStartAt}} lies below that watermark. Sealed (pre-watermark) 
records are treated as immutable run boundaries: a late event below the 
watermark either attaches without re-dicing sealed records or is rejected as 
too-late, and the new tracked set governs only sequences at or above the 
watermark. This makes the prospective model of Finding A genuinely correct 
rather than only correct-in-the-absence-of-late-events.

This is real algorithm work (a new persisted watermark, plus boundary logic in 
{{reconcileStartAndEndAt}} / the affected-row scan to respect it), not a 
validation relaxation. Sizing to be completed before implementation.

h2. Proposed sequencing

# *PR A* -- the {{allowMissingColumns}} union fix for column-schema evolution 
(claims 1 and 3). Independent, small, also closes SPARK-58418.
# *PR B* -- tracked-column-set change (claims 2 and 4) via the change-watermark 
design. Must be reconciled with SPARK-58391 (open PR #57625), which adds the 
track-history drift validation that PR B would relax/replace; the two should 
not be landed as add-then-remove.

h2. Verification note

All findings above were reproduced by prototyping {{allowMissingColumns = 
true}} and observing target-table contents across two-run scenarios (in-order, 
late-event, and multi-key). The scratch suites were not committed.



> Support tracking columns evolution for SCD Type 2
> -------------------------------------------------
>
>                 Key: SPARK-58452
>                 URL: https://issues.apache.org/jira/browse/SPARK-58452
>             Project: Spark
>          Issue Type: Sub-task
>          Components: Declarative Pipelines
>    Affects Versions: 4.3.0
>            Reporter: Andreas Neumann
>            Priority: Major
>
> h2. Goal
> Support schema evolution for SCD Type 2 AutoCDC targets, matching the 
> flexibility SCD Type 1 already has. Two independent capabilities are in 
> scope, and they are very different in difficulty:
> # *Column-schema evolution* -- the set of user data columns the flow emits 
> changes across runs (a column is dropped from the input, a nested field is 
> dropped, or the column/EXCEPT selection narrows), WITHOUT changing the 
> effective tracked-history column set. Tracked as SPARK-58418.
> # *Tracked-column-set change* -- the set of columns that define an SCD2 "run" 
> changes across runs (adding or removing a tracked column, whether explicitly 
> via {{TRACK HISTORY ON}} or implicitly through the default / {{* EXCEPT}} 
> resolution following an input-schema change).
> All statements below were verified empirically against master by prototyping 
> and observing the resulting target rows (scratch suites, not committed).
> h2. Background: how the tracked set is used
> In {{Scd2BatchProcessor.reconcileStartAndEndAt}}, two 
> chronologically-adjacent upsert events for the same key are coalesced into a 
> single record iff they are gap-free and equal on *every tracked column* 
> ({{isNoOpUpsertContinuation}}). A change in a tracked column opens a new 
> record; a change in a non-tracked column is folded into the current record. 
> So the tracked set is the sole determinant of where record boundaries fall.
> The reconciliation window is bounded per key: 
> {{findAffectedRowsFrom{Target,Auxiliary}Table}} pull in only rows at or after 
> {{minSeq}} (the minimum sequence of the *current microbatch* for that key), 
> plus a single aux "anchor" row. History below that point is normally never 
> revisited -- unless a late event drags it into the window.
> h2. Part 1 -- Column-schema evolution (SPARK-58418): small, ship first
> *Root cause of the current failure:* 
> {{Scd2ForeachBatchHandler.reconcileMicrobatch}} unions the microbatch with 
> the affected target/aux rows via {{unionByName}} with no 
> {{allowMissingColumns}}. A microbatch narrower than the (already-evolved, 
> wider) target then fails -- {{NUM_COLUMNS_MISMATCH}} for a dropped top-level 
> column, {{INCOMPATIBLE_COLUMN_TYPE}} for a dropped nested struct/array field.
> *Fix:* pass {{allowMissingColumns = true}} to the two {{unionByName}} calls. 
> Verified this fixes both the top-level and nested cases (the flag recurses 
> into structs/arrays; map types remain unsupported), preserves historical 
> values (the closing UPDATE draws from the target read, not the null-padded 
> microbatch), and is safe through the aux round-trip. No changes needed in the 
> merges, the aux schema (derived from the evolved target and evolved with 
> {{mergeWithExistingSchema = true}}), or the reconciliation transforms.
> This half is a one-line production change plus tests, and resolves 
> SPARK-58418. It covers only column evolution that leaves the tracked set 
> unchanged; PR A must NOT make the tracked set evolve (that is Part 2 and 
> requires the guard below, or it corrupts history -- see Findings).
> h2. Part 2 -- Tracked-column-set change: allow only via full refresh
> Allowing the tracked set to change and letting the stream continue 
> incrementally does *not* preserve correctness. Verified:
> * *Finding A (in-order):* changing the tracked set produces a temporal 
> patchwork -- records below the change keep the old segmentation, records from 
> the change onward use the new one.
> * *Finding B (late events):* a late-arriving event drops {{minSeq}} backward, 
> dragging pre-change history into the affected window, where it is re-diced 
> under the current tracked set. Narrowing then silently *destroys* pre-change 
> records (they coalesce); widening *fabricates* records (retroactive splits), 
> both with sequences below the change point. Reproduced in both directions, 
> including the implicit case where a default-tracked column leaves the input 
> schema and the effective set evolves to a subset.
> * *Finding C (blast radius):* the corruption is confined to keys present in 
> the triggering microbatch (the affected-row scans inner-join on the key 
> against the per-key {{minSeq}}), and reaches back to that key's batch-min. A 
> key that stays quiet keeps its old segmentation.
> Because incremental tracked-set changes are unsafe under the bounded-window 
> design, the chosen approach is to *disallow* them incrementally and require a 
> full refresh, which starts from a clean target and aux table so there is no 
> pre-change history to corrupt. Two rules:
> # *The effective tracked set is derived from the column selection.* Default = 
> all selected non-key/non-framework columns; {{* EXCEPT (...)}} = selected 
> minus the exclusions; explicit {{TRACK HISTORY ON (...)}} = the enumerated 
> list. So dropping a column from the selection (default / {{* EXCEPT}} modes) 
> correctly removes it from the tracked set -- it is not kept tracked just 
> because it lingers in the (sticky) target schema.
> # *A guard requires a full refresh whenever the effective tracked set 
> changes*, however it was configured. This turns claims 2, 3, and 4 -- and 
> claim 1 when the removed column was tracked -- into a clear, actionable 
> {{AUTOCDC_INVALID_STATE.TRACK_HISTORY_DRIFT}} error (remedy: full refresh) 
> instead of a crash or silent corruption.
> This needs no change watermark and no changes to {{reconcileStartAndEndAt}}; 
> it is a validation-plus-computation change.
> h3. Reuse of SPARK-58391
> SPARK-58391 (open PR apache/spark#57625) already implements exactly this 
> guard: it records the tracked set as the auxiliary-table property 
> {{pipelines.autocdc.trackHistoryColumnNames}} and rejects a changed set with 
> {{AUTOCDC_INVALID_STATE.TRACK_HISTORY_DRIFT}}. Part 2 reuses it directly, 
> with *one required change*:
> * SPARK-58391 currently computes the recorded/compared tracked set from the 
> *evolved target schema* ({{buildScd2AuxiliaryTableSpecFor}} passes 
> {{targetTableSchema}} to 
> {{Scd2BatchProcessor.computeTrackedHistoryColumns}}). Because the target 
> schema keeps columns that have been dropped from the input, this would fail 
> to detect the *implicit* evolution cases (default / {{* EXCEPT}} tracking 
> where an input column disappears): the recorded and current sets would both 
> still contain the dropped column, no drift is detected, and the run proceeds 
> into the corruption of Finding B.
> * Rule (1) requires deriving the tracked set from the *column-selection-based 
> (user-selected source) schema* instead -- the same {{userSelectedSchema}} 
> that the construction-time guard 
> {{Flow.requireTrackHistoryColumnsResolvableInSelectedSchema}} already uses -- 
> at both the recording site and the comparison, so implicit tracked-set 
> changes are detected and correctly demand a full refresh.
> So the direction for apache/spark#57625 is to land (or be amended) with the 
> selection-derived tracked set, at which point it directly becomes the Part 2 
> guard -- avoiding an add-then-relax churn.
> h2. Claim-by-claim outcome under this design
> * *Claim 1 (remove a column from the input schema):* incremental via the Part 
> 1 union fix if the column was not tracked; full refresh if it was tracked 
> (default / {{* EXCEPT}}).
> * *Claim 2 (add a new column as a tracking column):* full refresh.
> * *Claim 3 (remove a tracking column from the column selection):* full 
> refresh (default / {{* EXCEPT}}); already rejected at construction if the 
> column is explicitly tracked but dropped from the selection.
> * *Claim 4 (remove a column from the tracked set):* full refresh.
> Only column-schema evolution that leaves the tracked set unchanged stays 
> incremental; every tracked-set change surfaces a clear full-refresh error 
> rather than the current crash/corruption.
> h2. Proposed sequencing
> # *PR A -- SPARK-58418:* the {{allowMissingColumns}} union fix. Independent, 
> small, ship first. Must not evolve the tracked set.
> # *PR B -- this ticket:* rules (1)+(2), built on apache/spark#57625 
> (SPARK-58391) with the selection-derived-tracked-set change and tests for the 
> implicit evolution cases. No watermark.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to