fix(incremental): perform a real SCD2 load for the scd2 strategy#235
Conversation
- `--incremental-strategy scd2` now performs an SCD2 load: the validity columns
(`_dlt_valid_from` / `_dlt_valid_to`) are materialized, and a row that changes
in the source is retired and succeeded rather than overwritten. Previously the
option performed an SCD2 load on no path at all. dlt rejected it during
extract for ordinary sources (`write_disposition=scd2` is not one of
`skip`/`append`/`replace`/`merge`), and sources that own their write
disposition ignored it silently.
- Mechanism: `IncrementalStrategy` flattens dlt's two axes (write disposition x
merge strategy) onto one, and `scd2` lives on the second. It is now mapped to
dlt's structured `{"disposition": "merge", "strategy": "scd2"}`, next to the
existing `delete+insert` unflattening, instead of being passed through as a
write disposition dlt has no such value for.
- SCD2 has dlt compare the whole source table against the records it holds open
and retire whatever is missing from it. The combinations that misrepresent
that table are now refused rather than left to rewrite history silently:
- `--incremental-key`, `--sql-limit`, and `--yield-limit` each read back only
part of the table, retiring every record they leave out while the source
still holds it unchanged.
- A `--mask` algorithm that draws a fresh value per run (`date_shift`,
`noise`, `random`, `uuid`) rewrites the rows being compared, so every record
reads as changed and is retired and re-inserted on every load.
`masking.NON_DETERMINISTIC_ALGORITHMS` names them.
- The Arrow-yielding SQL backends (`pyarrow`, `connectorx`) and the `mmap://`
source carry no row hash, which dlt computes only for rows it receives as
dicts, leaving `_dlt_id` unpopulated and the load dead in `normalize`.
Populating it via `normalize.parquet_normalizer.add_dlt_id` is not an
alternative, as those ids are random per run rather than a hash of the row,
so every record would be retired and re-inserted on every load. SQL sources
are read with `sqlalchemy` for SCD2 instead. A `query:` table is exempt from
the backend rule, being read with sqlalchemy whichever backend is named, and
the guide states that such an expression is itself the tracked relation.
- `--primary-key` is not required for SCD2, as dlt hashes the whole row to spot
a change. It stays optional and marks the natural key on the destination.
- Unchanged: sources that manage their own incrementality still ignore the
run-level strategy, and the filesystem family still rejects the key-based
strategies.
- The flaw predates the fork (`3174960`, 2024-07-12) and survived because the
only `scd2` coverage was the filesystem reject path. The new tests exercise it
on the sources where it applies, including that a re-run over unchanged data
is a no-op rather than a retire-and-reinsert of every row.
|
Important Review skippedAuto reviews are limited based on label configuration. 🏷️ Required labels (at least one) (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #235 +/- ##
==========================================
+ Coverage 55.23% 55.37% +0.13%
==========================================
Files 210 210
Lines 9796 9830 +34
==========================================
+ Hits 5411 5443 +32
- Misses 4385 4387 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
amotl
left a comment
There was a problem hiding this comment.
Wow. I wasn't aware SCD2 was suppressed or even defunct due to different circumstances. Thank you so much for unlocking it swiftly! 💯
Summary
--incremental-strategy scd2has never performed an SCD2 load.IncrementalStrategyflattens dlt's two axes (write disposition x merge strategy) onto one, andscd2lives on the second, but it was passed straight onto the first. Ordinary sources died inextract(field `write_disposition=scd2` is not one of: ['skip', 'append', 'replace', 'merge']), and sources that own their write disposition ignored it silently. It is now mapped to dlt's structured{"disposition": "merge", "strategy": "scd2"}, next to the existingdelete+insertunflattening._dlt_valid_from/_dlt_valid_tomaterialized, and a re-run over unchanged data is a no-op.--incremental-key,--sql-limit, and--yield-limiteach read back only part of the table, retiring every record they leave out while the source still holds it unchanged.--maskalgorithm that draws a fresh value per run (date_shift,noise,random,uuid) rewrites the rows being compared, so every record reads as changed.masking.NON_DETERMINISTIC_ALGORITHMSnames them; deterministic algorithms such assha256are unaffected.sqlalchemybackend for SCD2, and an explicitpyarrow/connectorxbackend or anmmap://source is refused rather than dying later innormalizeon an unpopulated_dlt_id. Populating it vianormalize.parquet_normalizer.add_dlt_idis not an alternative: those ids are random per run rather than a hash of the row, so every record would be retired and re-inserted on every load. Aquery:table is exempt from the backend rule, being read with sqlalchemy whichever backend is named.--primary-keyis not required, as dlt hashes the whole row to spot a change. It stays optional and marks the natural key on the destination.scd2stays ignored, never applied) or for the filesystem family (key-based strategies still rejected).scd2joins the--incremental-strategyoption list, which previously named it only among the strategies filesystem rejects.The flaw is inherited from ingestr (
3174960, 2024-07-12) and survived because the onlyscd2coverage was the filesystem reject path, so nothing exercised it where it should work.Follows up on the
scd2flaw raised in #188.Test plan
--incremental-key/--sql-limit/--yield-limit), non-deterministic mask rejection and a deterministic mask still loading, Arrow backend andmmap://rejection, rejection surfacing under--dry-run, the backend rule not firing for non-SQL orquery:sources, default backend selection, and a managed-source regression guard--sql-limit 1retired two unchanged records; arandommask gave 4 rows after 2 identical runs)poe lintclean (ruff format/check, validate-pyproject, ty)poe test-fast: 641 passed, 44 skipped (skips are unset SaaS credentials)poe docs-htmlclean under-Wscd2reject-path test stays greenscd2, and the new logic is gated behind it