Skip to content

mssql: use native Arrow bulk copy for Parquet load jobs #13

Description

@sdebruyn

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

  1. Replace MssqlParquetCopyJob(AdbcParquetCopyJob) with a native mssql-python runnable job.
  2. 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.
  3. Pass a fully qualified target table and explicit source-to-destination column mappings so schema order and identifier casing are not implicit.
  4. 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.
  5. Remove MSSQL-specific ODBC-to-Go DSN conversion and the row-group buffering workaround when the native path is proven equivalent.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions