[
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. Status and sequencing
# *PR A -- SPARK-58418 (DONE):* the {{allowMissingColumns}} union fix for
column-schema evolution that leaves the tracked set unchanged. Implemented and
pushed; does not evolve the tracked set.
# *PR B -- this ticket:* rules (1)+(2). *These are now delivered by
apache/spark#57625 (SPARK-58391).* That PR's latest revision derives the
tracked set from the user-selected source schema (rule 1, via a new
{{AutoCdcMergeFlow.trackHistoryColumnNames}} val) and rejects any change to it
with {{TRACK_HISTORY_DRIFT}} (rule 2), including the implicit default / {{*
EXCEPT}} cases where a source column is added or dropped. So no separate
watermark work and no add-then-relax churn: once #57625 merges, the
tracked-set-change semantics for this ticket are in place, and this ticket's
remaining scope is documentation / any additional end-to-end coverage on top of
it.
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,
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.
> 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. Status and sequencing
> # *PR A -- SPARK-58418 (DONE):* the {{allowMissingColumns}} union fix for
> column-schema evolution that leaves the tracked set unchanged. Implemented
> and pushed; does not evolve the tracked set.
> # *PR B -- this ticket:* rules (1)+(2). *These are now delivered by
> apache/spark#57625 (SPARK-58391).* That PR's latest revision derives the
> tracked set from the user-selected source schema (rule 1, via a new
> {{AutoCdcMergeFlow.trackHistoryColumnNames}} val) and rejects any change to
> it with {{TRACK_HISTORY_DRIFT}} (rule 2), including the implicit default /
> {{* EXCEPT}} cases where a source column is added or dropped. So no separate
> watermark work and no add-then-relax churn: once #57625 merges, the
> tracked-set-change semantics for this ticket are in place, and this ticket's
> remaining scope is documentation / any additional end-to-end coverage on top
> of it.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]