feat(fabric): support staging-optimized replace via DDL transactions#4142
Open
sdebruyn wants to merge 2 commits into
Open
feat(fabric): support staging-optimized replace via DDL transactions#4142sdebruyn wants to merge 2 commits into
sdebruyn wants to merge 2 commits into
Conversation
sdebruyn
added a commit
to sdebruyn/dlt
that referenced
this pull request
Jun 30, 2026
This branch tracks dlt and bundles the Fabric-related pull requests (dlt-hub#4140, dlt-hub#4141, dlt-hub#4142) for combined testing. Point everything else to the upstream dlt project. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1b435ed to
1a09138
Compare
sdebruyn
added a commit
to sdebruyn/dlt
that referenced
this pull request
Jul 1, 2026
Add dlt-hub#4147 (injectable access token / Azure TokenCredential for the MS SQL destinations) to the bundled pull request list, and note that dlt-hub#4142 now also carries the concurrent-load safety fix for staging-optimized replace. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sdebruyn
added a commit
to sdebruyn/dlt
that referenced
this pull request
Jul 1, 2026
Add dlt-hub#4147 (injectable access token / Azure TokenCredential for the MS SQL destinations) to the bundled pull request list, and note that dlt-hub#4142 now also carries the concurrent-load safety fix for staging-optimized replace.
Unlike Synapse dedicated SQL pools, Fabric Warehouse supports DDL transactions and `ALTER SCHEMA ... TRANSFER`. Override the synapse-inherited capabilities (`supports_ddl_transactions`, `supported_replace_strategies`) to advertise the `staging-optimized` replace strategy. `SynapseClient._create_replace_followup_jobs` always falls back to the generic truncate-and-insert replace job, bypassing the `ALTER SCHEMA ... TRANSFER`-based job that `MsSqlJobClient` uses for `staging-optimized` (since Synapse doesn't support DDL transactions). `FabricClient` now overrides `_create_replace_followup_jobs` to route the `staging-optimized` strategy to that mssql job, while keeping the existing Synapse-style behavior for `truncate-and-insert` and `insert-from-staging`.
Multi-chain loads with write_disposition="replace" and replace strategy
"staging-optimized" silently lost child-table data for the 2nd and later
table chains when the load step ran with its default thread-pool
concurrency. Root cause was two compounding bugs:
1. `SqlLoadJob._has_out_of_transaction_commands` unconditionally treated
any SQL containing "ALTER SCHEMA" as out-of-transaction (a leftover
from a Synapse-only workaround - Synapse never actually reaches this
code path, since `SynapseClient._create_replace_followup_jobs` always
falls back to the generic clone/insert replace job). In practice this
only ever matched `MsSqlStagingReplaceJob.generate_sql`'s
DROP/ALTER SCHEMA TRANSFER/SELECT INTO swap sequence, defeating
transactional wrapping for both mssql and fabric even though both
declare `supports_ddl_transactions = True`. Each table's swap ran
autocommitted, statement by statement, instead of atomically.
2. Even after restoring per-job atomicity, concurrent replace jobs for
different table chains still raced: Fabric Warehouse's distributed
catalog does not safely isolate concurrent `ALTER SCHEMA ... TRANSFER`
transactions against the same destination schema, so one job's
already-committed swap could be silently lost when another job's
swap landed concurrently. `sp_getapplock`, the usual T-SQL primitive
for serializing this, is not supported by Fabric Warehouse
("PROCEDURE 'sp_getapplock' is not supported.", confirmed live).
Fix:
- Remove the blanket "ALTER SCHEMA" exclusion from
`_has_out_of_transaction_commands`, restoring real transactional
atomicity to `MsSqlStagingReplaceJob` on both mssql and fabric. The
Synapse-specific exclusion (by class name) is untouched, and synapse's
own `supports_ddl_transactions = False` already makes the removed
check redundant for it.
- Add `FabricStagingReplaceLoadJob`, a fabric-only `SqlLoadJob` subclass
that serializes execution of the staging-optimized replace swap with
an in-process lock keyed by destination dataset. The loader's worker
pool is always thread-based for sql jobs
(`LoaderConfiguration.on_resolved` forces `pool_type = "thread"`
unless there is a single worker), so an in-process lock is sufficient
and does not need to be cross-process. `FabricClient.create_load_job`
routes to it only for "sql" jobs on replace tables using the
staging-optimized strategy; truncate-and-insert and
insert-from-staging are unaffected, as is mssql (which keeps using the
plain `SqlLoadJob` and is not known to need serialization - see below).
`dlt/destinations/impl/mssql/mssql.py` (the shared `MsSqlStagingReplaceJob`
class) is NOT modified - only the generic `job_client_impl.py` transaction
check and fabric-only `fabric.py` are touched.
mssql impact: the transaction fix (point 1) applies to mssql too, since
`MsSqlStagingReplaceJob` is shared - it was silently non-atomic there as
well, which is a real latent bug fixed for free. The serialization fix
(point 2) is fabric-only. mssql is a traditional locked RDBMS instance;
concurrent `ALTER SCHEMA ... TRANSFER` statements against the same schema
are expected to be serialized by SQL Server's own schema-level metadata
locking rather than racing the way Fabric's distributed catalog does.
This is reasoned, not live-verified (no SQL Server instance was
available).
Also fixes a test-spy gap: `test_replace_sql_queries` only special-cased
["postgres", "mssql", "clickhouse"] for a destination-specific replace-job
spy; fabric now uses the same `MsSqlStagingReplaceJob` as mssql and needs
the same spy assertion.
Validated live against a Fabric Warehouse on the integration/fabric-full
branch (Entra ID auth + mssql-python driver + staging-optimized replace
combined, cli auth working out of the box), with the default thread-pool
concurrency (no LOAD__WORKERS=1 workaround):
- test_replace_table_clearing[staging-optimized-fabric-no-staging]:
3/3 consecutive passes (was reliably failing before the fix)
- test_replace_sql_queries[staging-optimized-fabric-no-staging]: pass
- test_write_dispositions[fabric-no-staging-replace-staging-optimized]: pass
- tests/load/fabric/test_fabric_table_builder.py: 11/11 pass
c1c5b66 to
a5898c3
Compare
This was referenced Jul 1, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Fabric Warehouse, unlike Synapse dedicated SQL pools, supports DDL transactions and
ALTER SCHEMA ... TRANSFER. This PR enables thestaging-optimizedreplace strategy for thefabricdestination.Three parts:
Capabilities (
dlt/destinations/impl/fabric/factory.py): override the Synapse-inherited capabilities to advertise DDL transactions and the optimized strategy:supports_ddl_transactions = Truesupported_replace_strategies = ["truncate-and-insert", "insert-from-staging", "staging-optimized"]Replace-job routing (
dlt/destinations/impl/fabric/fabric.py):SynapseClient._create_replace_followup_jobsdeliberately bypassesMsSqlJobClient's replace logic and always falls back to the generic truncate-and-insert job, because Synapse dedicated pools do not support DDL transactions.FabricClientinherited that bypass, so flipping the capabilities alone would advertisestaging-optimizedwithout ever emittingALTER SCHEMA ... TRANSFER, silently behaving likeinsert-from-staging.FabricClientnow overrides_create_replace_followup_jobsto route thestaging-optimizedstrategy to the sameMsSqlStagingReplaceJob(ALTER SCHEMA TRANSFER) the mssql destination uses, while keeping the Synapse-style behavior fortruncate-and-insertandinsert-from-staging.Concurrent-load safety (
dlt/destinations/job_client_impl.py,dlt/destinations/impl/fabric/fabric.py).The swap generated by
MsSqlStagingReplaceJob(DROP TABLE,ALTER SCHEMA ... TRANSFER,SELECT ... INTO) has to run as one atomic transaction, but it was running fully autocommitted, statement by statement. With several table chains replaced concurrently this silently dropped rows: a second chain's swap could interleave with a first still mid-swap, and one chain's data was lost.The cause was
SqlLoadJob._has_out_of_transaction_commands, which forces a job to run outside a transaction. It had two triggers: a blanketUNLOGGED_COMMANDS = ["ALTER SCHEMA"]match, and a check forSynapseClient. WhyUNLOGGED_COMMANDS = ["ALTER SCHEMA"]is removed here:# TODO: move to respective sql clientsnote, i.e. it was flagged as a blanket check living in the wrong place;SynapseClienttrigger (kept) already forces every Synapse sql job into autocommit, so theALTER SCHEMAmatch was redundant for Synapse, which in any case never emits this swap (its_create_replace_followup_jobsalways falls back to the clone/insert job);ALTER SCHEMAisMsSqlStagingReplaceJob.generate_sql(dlt/destinations/impl/mssql/mssql.py), used bymssqlandfabric. Both advertisesupports_ddl_transactions = Trueand are notSynapseClient, so this match was the only thing forcing their swap out of a transaction, which is exactly what broke atomicity.Removing the constant therefore leaves Synapse unchanged (still autocommit via the
SynapseClienttrigger, and it never reaches the swap) and restores the intended atomic swap for bothfabricandmssql(the mssql case was a latent bug fixed here as well).Atomicity alone is not enough on Fabric: it has no
sp_getapplock, so two atomic swaps for different chains committed at the same time can still race against the same destination schema. A fabric-specificFabricStagingReplaceLoadJobserializes the swap with an in-process lock keyed by destination dataset, which is sufficient because the loader always runs sql jobs on a thread pool, never across processes. The lock is fabric-specific: mssql relies on the SQL Server engine's own schema-level locking and is left unchanged.Related Issues
Additional Context
Tests in
tests/load/fabric/test_fabric_table_builder.py:test_fabric_capabilitiesasserts DDL transactions andstaging-optimizedare advertised;test_fabric_staging_optimized_replace_uses_alter_schema_transferasserts the generated replace-job SQL containsALTER SCHEMA,TRANSFERandDROP TABLE IF EXISTS;test_fabric_insert_from_staging_replace_unaffectedguards that the non-optimized strategies stay unchanged.tests/load/pipeline/test_replace_disposition.py::test_replace_sql_queriesnow also coversfabric, which reuses mssql'sMsSqlStagingReplaceJob.Validated end to end against a live Fabric Warehouse: the multi-table
staging-optimizedreplace keeps every child-table row across repeated concurrent runs, and the single-chain and non-optimized strategies are unaffected.