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

Andreas Neumann updated SPARK-58452:
------------------------------------
    Summary: Support tracking columns evolution for SCD Type 2  (was: Support 
schema evolution for SCD Type 2)

> 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:
> # *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]

Reply via email to