Skip to content

feat(fabric): support staging-optimized replace via DDL transactions#4142

Open
sdebruyn wants to merge 2 commits into
dlt-hub:develfrom
sdebruyn:feat/fabric-staging-optimized
Open

feat(fabric): support staging-optimized replace via DDL transactions#4142
sdebruyn wants to merge 2 commits into
dlt-hub:develfrom
sdebruyn:feat/fabric-staging-optimized

Conversation

@sdebruyn

@sdebruyn sdebruyn commented Jun 30, 2026

Copy link
Copy Markdown

Description

Fabric Warehouse, unlike Synapse dedicated SQL pools, supports DDL transactions and ALTER SCHEMA ... TRANSFER. This PR enables the staging-optimized replace strategy for the fabric destination.

Three parts:

  1. Capabilities (dlt/destinations/impl/fabric/factory.py): override the Synapse-inherited capabilities to advertise DDL transactions and the optimized strategy:

    • supports_ddl_transactions = True
    • supported_replace_strategies = ["truncate-and-insert", "insert-from-staging", "staging-optimized"]
  2. Replace-job routing (dlt/destinations/impl/fabric/fabric.py): SynapseClient._create_replace_followup_jobs deliberately bypasses MsSqlJobClient's replace logic and always falls back to the generic truncate-and-insert job, because Synapse dedicated pools do not support DDL transactions. FabricClient inherited that bypass, so flipping the capabilities alone would advertise staging-optimized without ever emitting ALTER SCHEMA ... TRANSFER, silently behaving like insert-from-staging. FabricClient now overrides _create_replace_followup_jobs to route the staging-optimized strategy to the same MsSqlStagingReplaceJob (ALTER SCHEMA TRANSFER) the mssql destination uses, while keeping the Synapse-style behavior for truncate-and-insert and insert-from-staging.

  3. 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 blanket UNLOGGED_COMMANDS = ["ALTER SCHEMA"] match, and a check for SynapseClient. Why UNLOGGED_COMMANDS = ["ALTER SCHEMA"] is removed here:

    • it already carried a # TODO: move to respective sql clients note, i.e. it was flagged as a blanket check living in the wrong place;
    • the SynapseClient trigger (kept) already forces every Synapse sql job into autocommit, so the ALTER SCHEMA match was redundant for Synapse, which in any case never emits this swap (its _create_replace_followup_jobs always falls back to the clone/insert job);
    • the only SQL in the whole codebase that contains ALTER SCHEMA is MsSqlStagingReplaceJob.generate_sql (dlt/destinations/impl/mssql/mssql.py), used by mssql and fabric. Both advertise supports_ddl_transactions = True and are not SynapseClient, 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 SynapseClient trigger, and it never reaches the swap) and restores the intended atomic swap for both fabric and mssql (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-specific FabricStagingReplaceLoadJob serializes 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_capabilities asserts DDL transactions and staging-optimized are advertised;
  • test_fabric_staging_optimized_replace_uses_alter_schema_transfer asserts the generated replace-job SQL contains ALTER SCHEMA, TRANSFER and DROP TABLE IF EXISTS;
  • test_fabric_insert_from_staging_replace_unaffected guards that the non-optimized strategies stay unchanged.

tests/load/pipeline/test_replace_disposition.py::test_replace_sql_queries now also covers fabric, which reuses mssql's MsSqlStagingReplaceJob.

Validated end to end against a live Fabric Warehouse: the multi-table staging-optimized replace keeps every child-table row across repeated concurrent runs, and the single-chain and non-optimized strategies are unaffected.

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>
@sdebruyn sdebruyn force-pushed the feat/fabric-staging-optimized branch from 1b435ed to 1a09138 Compare June 30, 2026 22:32
@sdebruyn sdebruyn marked this pull request as ready for review July 1, 2026 07:53
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.
sdebruyn added 2 commits July 1, 2026 10:08
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fabric Warehouse: support the staging-optimized replace strategy

1 participant