[
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:
# *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.
was:
Currently, SCD Type 2 supports only one type of schema evolution: adding a
non-key, non-tracking column. All other types of evolution fail:
# removing a column from the input schema
# adding a new column as a tracking column
# removing a tracking column from the column selection
# removing a column from the tracked column set
This is troublesome, especially because all of these can be triggered
implicitly by a change in the input schema, if column selection or tracking
column selection not configured (that is, defaults to all columns), or
configured as `* EXCEPT ...`.
This is not a problem in SCD Type 1, and SCD Type 2 should match this
capability.
> Support schema 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:
> # *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.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]