Context
The current MSSQL Parquet path is implemented through ADBC:
MssqlParquetCopyJob subclasses the generic AdbcParquetCopyJob, opens a separate Go MSSQL connection, and translates ODBC DSN values into that driver's format:
|
class MssqlParquetCopyJob(AdbcParquetCopyJob): |
|
_config: MsSqlClientConfiguration |
|
# mssql ADBC driver buffers the full input stream in memory; flush per row-group |
|
# to bound peak memory and avoid OOM on large parquet files |
|
_ingest_per_rowgroup: bool = True |
|
|
|
if TYPE_CHECKING: |
|
from adbc_driver_manager.dbapi import Connection |
|
|
|
def _connect(self) -> "Connection": |
|
from adbc_driver_manager import dbapi |
|
|
|
self._config = self._job_client.config # type: ignore[assignment] |
|
conn_dsn = self.odbc_to_go_mssql_dsn(self._config.credentials.get_odbc_dsn_dict()) |
|
conn_str = build_odbc_dsn(conn_dsn) |
|
return dbapi.connect(driver="mssql", db_kwargs={"uri": conn_str}) |
|
|
|
@staticmethod |
|
def odbc_to_go_mssql_dsn(dsn: Dict[str, Any]) -> Dict[str, Any]: |
|
"""Converts odbc connection string to go connection string used by ADBC""" |
|
# DSN keys are already normalized to upper case |
|
result: Dict[str, Any] = {} |
|
|
|
for upper, value in dsn.items(): |
|
if value is None: |
|
continue |
|
|
|
v = str(value) |
|
|
|
if upper == "ENCRYPT": |
|
v = v.strip().lower() |
|
|
|
# ODBC: yes/mandatory/true/1 → go-mssqldb: true (TLS on) |
|
if v in {"yes", "true", "1", "mandatory"}: |
|
v = "true" |
|
|
|
# ODBC: strict → go-mssqldb strict (if supported by the driver) |
|
elif v in {"strict"}: |
|
v = "strict" |
|
|
|
# ODBC: optional → go-mssqldb optional (login only) |
|
elif v in {"optional"}: |
|
v = "optional" |
|
|
|
# ODBC: no/false/0/disabled → go-mssqldb disable (no TLS at all) |
|
# This mirrors your previous string hack: |
|
# .replace("=yes", "=1").replace("=no", "=disable") |
|
elif v in {"no", "false", "0", "disabled", "disable"}: |
|
v = "disable" |
|
|
|
elif upper == "TRUSTSERVERCERTIFICATE": |
|
v = v.strip().lower() |
|
|
|
# ODBC uses yes/no; go-mssqldb expects true/false (but is lenient); |
|
# we normalize explicitly. |
|
if v in {"yes", "true", "1"}: |
- The job is selected for Parquet files here:
|
def create_load_job( |
|
self, table: PreparedTableSchema, file_path: str, load_id: str, restore: bool = False |
|
) -> LoadJob: |
|
job = super().create_load_job(table, file_path, load_id, restore) |
|
if not job: |
|
parsed_file = ParsedLoadJobFileName.parse(file_path) |
|
if parsed_file.file_format == "parquet": |
|
job = MssqlParquetCopyJob(file_path) |
|
return job |
- Parquet availability and preference depend on a separately installed ADBC driver:
|
def _raw_capabilities(self) -> DestinationCapabilitiesContext: |
|
caps = DestinationCapabilitiesContext() |
|
caps.preferred_loader_file_format = "insert_values" |
|
caps.supported_loader_file_formats = ["insert_values", "parquet", "model"] |
|
caps.loader_file_format_selector = make_adbc_parquet_file_format_selector( |
|
"mssql", |
|
"https://dlthub.com/docs/dlt-ecosystem/destinations/mssql#data-loading", |
|
prefer_parquet=True, |
|
) |
- ADBC buffers the full input stream, so dlt works around that by invoking ingest once per Parquet row group:
|
class MssqlParquetCopyJob(AdbcParquetCopyJob): |
|
_config: MsSqlClientConfiguration |
|
# mssql ADBC driver buffers the full input stream in memory; flush per row-group |
|
# to bound peak memory and avoid OOM on large parquet files |
|
_ingest_per_rowgroup: bool = True |
|
|
|
if TYPE_CHECKING: |
|
from adbc_driver_manager.dbapi import Connection |
|
|
|
def _connect(self) -> "Connection": |
|
from adbc_driver_manager import dbapi |
|
|
|
self._config = self._job_client.config # type: ignore[assignment] |
|
conn_dsn = self.odbc_to_go_mssql_dsn(self._config.credentials.get_odbc_dsn_dict()) |
|
conn_str = build_odbc_dsn(conn_dsn) |
|
return dbapi.connect(driver="mssql", db_kwargs={"uri": conn_str}) |
mssql-python v1.13.0 / PR #665 adds Cursor.bulkcopy_arrow(table_name, source). It accepts pyarrow.Table, RecordBatch, RecordBatchReader, iterables of record batches, and Arrow C Data Interface producers. It preserves Arrow memory, returns copied-row statistics, supports column mappings, and shares the driver's SQL/Entra authentication setup.
This gives us a path to remove an MSSQL-only second driver stack and its DSN conversion code while retaining streamed Parquet loading.
This depends on #10. It should integrate with #11 so azure_credential remains available to bulk copy for fresh token acquisition.
Proposed change
- Replace
MssqlParquetCopyJob(AdbcParquetCopyJob) with a native mssql-python runnable job.
- Stream the existing dlt Parquet batches into
bulkcopy_arrow; do not materialize the full file. A RecordBatchReader or bounded iterable built from pq_stream_with_new_columns is appropriate.
- Pass a fully qualified target table and explicit source-to-destination column mappings so schema order and identifier casing are not implicit.
- Replace ADBC-driver detection in the MSSQL loader-file-format selector with detection of the optional Arrow dependency/native API. Keep Arrow optional for users who only use
insert_values; do not make a heavy PyArrow install mandatory without a separate justification.
- Remove MSSQL-specific ODBC-to-Go DSN conversion and the row-group buffering workaround when the native path is proven equivalent.
- Keep an ADBC fallback only if the validation matrix finds a concrete native-driver gap; otherwise remove it from the MSSQL path.
Required validation before making it the preferred path
- Verify commit/rollback and partial-failure semantics.
bulkcopy_arrow creates its own native bulk-copy context, so a failed/retried dlt load job must not silently leave a committed prefix that is duplicated on retry.
- Verify append, merge staging, and both replace strategies, including restore/retry behavior.
- Cover SQL login, built-in
Authentication=, explicit access_token, azure_credential/token_provider, and Fabric NotebookUtils credentials.
- Cover dlt's type matrix: nullable numerics, decimal/wei, UUID, binary/LOB, JSON/text, date/time/timestamp, timezone behavior, and empty files.
- Verify table/schema quoting, case folding, reordered columns, and destination column mappings.
- Compare peak RSS and throughput against both current ADBC Parquet loading and
insert_values on representative row groups and large varbinary(max) data.
- Prove memory remains bounded across many row groups.
Acceptance criteria
- A Parquet load can run with
mssql-python>=1.13.0 plus PyArrow and without adbc_driver_mssql/adbc_driver_manager.
- The preferred format becomes Parquet only when its optional runtime requirements are present; existing insert-values-only installs remain lightweight.
- Load jobs report copied row counts and surface native failures through dlt's load-job exception handling.
- Retry behavior is demonstrated to be idempotent or the job is explicitly made so.
- MSSQL-specific ADBC connection and DSN conversion code is removed unless a tested fallback is retained and documented.
- End-to-end MSSQL load tests pass for normal, staged merge, and replace flows.
Context
The current MSSQL Parquet path is implemented through ADBC:
MssqlParquetCopyJobsubclasses the genericAdbcParquetCopyJob, opens a separate Go MSSQL connection, and translates ODBC DSN values into that driver's format:dlt/dlt/destinations/impl/mssql/mssql.py
Lines 93 to 148 in a831096
dlt/dlt/destinations/impl/mssql/mssql.py
Lines 180 to 188 in a831096
dlt/dlt/destinations/impl/mssql/factory.py
Lines 120 to 128 in a831096
dlt/dlt/destinations/impl/mssql/mssql.py
Lines 93 to 108 in a831096
mssql-python v1.13.0 / PR #665 adds
Cursor.bulkcopy_arrow(table_name, source). It acceptspyarrow.Table,RecordBatch,RecordBatchReader, iterables of record batches, and Arrow C Data Interface producers. It preserves Arrow memory, returns copied-row statistics, supports column mappings, and shares the driver's SQL/Entra authentication setup.This gives us a path to remove an MSSQL-only second driver stack and its DSN conversion code while retaining streamed Parquet loading.
This depends on #10. It should integrate with #11 so
azure_credentialremains available to bulk copy for fresh token acquisition.Proposed change
MssqlParquetCopyJob(AdbcParquetCopyJob)with a native mssql-python runnable job.bulkcopy_arrow; do not materialize the full file. ARecordBatchReaderor bounded iterable built frompq_stream_with_new_columnsis appropriate.insert_values; do not make a heavy PyArrow install mandatory without a separate justification.Required validation before making it the preferred path
bulkcopy_arrowcreates its own native bulk-copy context, so a failed/retried dlt load job must not silently leave a committed prefix that is duplicated on retry.Authentication=, explicitaccess_token,azure_credential/token_provider, and Fabric NotebookUtils credentials.insert_valueson representative row groups and largevarbinary(max)data.Acceptance criteria
mssql-python>=1.13.0plus PyArrow and withoutadbc_driver_mssql/adbc_driver_manager.