mirror of
https://github.com/apache/airflow.git
synced 2026-11-04 07:12:48 +00:00
Use bulk ingest in AdbcHook.insert_rows when fast_executemany is set (#76363)
* Use bulk ingest in AdbcHook.insert_rows when fast_executemany is set AdbcHook.insert_rows built an Arrow RecordBatch per commit_every chunk and bound it with cursor.executemany. Several ADBC drivers run a bound stream once per row, so the previous fast_executemany path was a no-op that set cursor.fast_executemany inside suppress(AttributeError). Route fast_executemany (now the default) to cursor.adbc_ingest in append mode. Keep executemany when fast_executemany is False, and always bind when replace=True because bulk ingest cannot carry the dialect's conflict clause. Chunking, commits, target_fields, and schema.table splitting are unchanged. Co-authored-by: Aldo <aoelvp94@gmail.com> * Fall back to binding when the ADBC driver has no bulk ingest Co-authored-by: Aldo <aoelvp94@gmail.com> * Use Logger spec and patch decorators in ADBC insert_rows tests Match AGENTS.md: spec every mock, and prefer @mock.patch.object over a with-block for the replace=True insert_rows tests. Co-authored-by: Aldo <aoelvp94@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
1 parent
fdeff70fc0
commit
50a2f28840
3 files changed
+220
-82
No files matched your search
@@ -186,7 +186,9 @@ Use :class:`~airflow.providers.apache.arrow.hooks.adbc.AdbcHook` in a task to qu
|
||||
# Run a query and get results as a list of tuples
|
||||
records = hook.get_records("SELECT id, name FROM users WHERE active = 1")
|
||||
|
||||
# Bulk-insert rows in chunks of 5 000 using Arrow-native transfer
|
||||
# Insert rows in chunks of 5 000. By default each chunk is loaded with the
|
||||
# driver's bulk ingest (e.g. COPY on PostgreSQL); pass fast_executemany=False
|
||||
# to bind the rows to an INSERT statement instead.
|
||||
rows = [(1, "Alice"), (2, "Bob")]
|
||||
hook.insert_rows(
|
||||
table="users",
|
||||
|
||||
@@ -17,17 +17,16 @@
|
||||
# under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from contextlib import closing
|
||||
from functools import cached_property
|
||||
from functools import cached_property, partial
|
||||
from importlib import resources as importlib_resources
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from adbc_driver_manager.dbapi import Connection, connect
|
||||
from adbc_driver_manager.dbapi import Connection, NotSupportedError, connect
|
||||
from more_itertools import chunked
|
||||
from pyarrow import RecordBatch, Schema, array, schema
|
||||
|
||||
@@ -78,7 +77,8 @@ class AdbcHook(DbApiHook):
|
||||
- Handles dialect-specific connection URIs and driver entrypoints.
|
||||
- Integrates with Airflow's connection system (conn_id, extras, etc.).
|
||||
- Provides custom placeholder replacement for parameterized SQL queries.
|
||||
- Supports both native Arrow binding and DBAPI ``executemany`` for inserts.
|
||||
- Loads rows with the driver's bulk ingest (``adbc_ingest``, e.g. ``COPY`` on PostgreSQL)
|
||||
by default, or binds them with DBAPI ``executemany`` (``fast_executemany=False``).
|
||||
- Exposes configuration via connection extras: driver, entrypoint, db_kwargs, conn_kwargs, dialect.
|
||||
|
||||
Connection Extras:
|
||||
@@ -238,34 +238,58 @@ class AdbcHook(DbApiHook):
|
||||
schema=schema,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _execute_executemany(cls, cursor, statement: str, record_batch: RecordBatch) -> None:
|
||||
"""
|
||||
Execute a statement using cursor.executemany with a RecordBatch.
|
||||
|
||||
ADBC's executemany() accepts Arrow data directly and routes it through
|
||||
bind_stream internally, making this the Arrow-native fast path.
|
||||
"""
|
||||
cursor.executemany(statement, record_batch)
|
||||
|
||||
def _resolve_execute_batch(
|
||||
self, cursor, executemany: bool, fast_executemany: bool
|
||||
) -> Callable[[Cursor, str, RecordBatch], None]:
|
||||
self,
|
||||
cursor: Cursor,
|
||||
*,
|
||||
table_name: str,
|
||||
schema_name: str | None,
|
||||
sql: str,
|
||||
replace: bool,
|
||||
fast_executemany: bool,
|
||||
) -> Callable[[RecordBatch], object]:
|
||||
"""
|
||||
Return the batch-execute callable for the given cursor.
|
||||
Return the callable that loads one RecordBatch through ``cursor``.
|
||||
|
||||
ADBC's executemany() natively accepts a RecordBatch, so it is always
|
||||
the fast path. fast_executemany is passed through to drivers that
|
||||
expose that knob (silently ignored otherwise).
|
||||
With ``fast_executemany`` the batch goes through the driver's bulk ingest
|
||||
(:meth:`adbc_driver_manager.dbapi.Cursor.adbc_ingest`, ``COPY`` on PostgreSQL).
|
||||
Otherwise it is bound to the generated ``INSERT`` statement with
|
||||
``cursor.executemany``, which most drivers execute once per row.
|
||||
``replace=True`` always binds, because bulk ingest cannot carry the
|
||||
dialect's conflict clause.
|
||||
|
||||
``fast_executemany`` is a hint: if the driver does not implement bulk ingest
|
||||
(the first ``adbc_ingest`` call raises ``NotSupportedError``), that batch and
|
||||
all following ones are bound with ``cursor.executemany`` instead.
|
||||
"""
|
||||
if fast_executemany:
|
||||
with contextlib.suppress(AttributeError):
|
||||
cursor.fast_executemany = True
|
||||
bind = partial(cursor.executemany, sql)
|
||||
if replace or not fast_executemany:
|
||||
if replace and fast_executemany:
|
||||
self.log.debug("Bulk ingest cannot replace rows; binding them to the statement instead")
|
||||
return bind
|
||||
|
||||
self.log.info("Using bulk ingest (adbc_ingest) for conn_id '%s'", self.get_conn_id())
|
||||
ingest = partial(cursor.adbc_ingest, table_name, mode="append", db_schema_name=schema_name)
|
||||
loader: Callable[[RecordBatch], object] | None = None
|
||||
|
||||
def execute_batch(batch: RecordBatch) -> object:
|
||||
nonlocal loader
|
||||
if loader is not None:
|
||||
return loader(batch)
|
||||
try:
|
||||
result = ingest(batch)
|
||||
except NotSupportedError:
|
||||
self.log.info(
|
||||
"Fast_executemany is enabled for conn_id '%s'!",
|
||||
"Driver for conn_id '%s' does not support bulk ingest; "
|
||||
"binding rows with executemany instead",
|
||||
self.get_conn_id(),
|
||||
)
|
||||
return self._execute_executemany
|
||||
loader = bind
|
||||
return bind(batch)
|
||||
loader = ingest
|
||||
return result
|
||||
|
||||
return execute_batch
|
||||
|
||||
def insert_rows(
|
||||
self,
|
||||
@@ -276,27 +300,31 @@ class AdbcHook(DbApiHook):
|
||||
replace=False,
|
||||
*,
|
||||
executemany=False,
|
||||
fast_executemany=False,
|
||||
fast_executemany=True,
|
||||
autocommit=False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Insert a collection of tuples into a table.
|
||||
|
||||
Rows are inserted in chunks, each chunk (of size ``commit_every``) is
|
||||
done in a new transaction.
|
||||
Rows are converted to Arrow ``RecordBatch`` objects and inserted in chunks;
|
||||
each chunk (of size ``commit_every``) is done in a new transaction.
|
||||
|
||||
:param table: Name of the target table
|
||||
:param table: Name of the target table, optionally prefixed with its schema
|
||||
(``schema.table``).
|
||||
:param rows: The rows to insert into the table
|
||||
:param target_fields: The names of the columns to fill in the table
|
||||
:param commit_every: The maximum number of rows to insert in one
|
||||
transaction. Set to 0 to insert all rows in one transaction.
|
||||
:param replace: Whether to replace instead of insert
|
||||
:param executemany: If True, all rows are inserted at once in
|
||||
chunks defined by the commit_every parameter. This only works if all rows
|
||||
have same number of column names, but leads to better performance.
|
||||
:param fast_executemany: If True, the `fast_executemany` parameter will be set on the
|
||||
cursor used by `executemany` which leads to better performance, if supported by driver.
|
||||
:param replace: Whether to replace instead of insert. Replacing always binds
|
||||
the rows to the generated statement, because bulk ingest cannot express it.
|
||||
:param executemany: Kept for signature compatibility with
|
||||
:meth:`~airflow.providers.common.sql.hooks.sql.DbApiHook.insert_rows`;
|
||||
rows are always sent as one Arrow batch per chunk.
|
||||
:param fast_executemany: If True (default), load each batch with the driver's
|
||||
bulk ingest (``cursor.adbc_ingest``, ``COPY`` on PostgreSQL). If the driver
|
||||
does not implement bulk ingest, falls back to binding. If False, bind
|
||||
each batch to the ``INSERT`` statement with ``cursor.executemany``.
|
||||
:param autocommit: What to set the connection's autocommit setting to
|
||||
before executing the query.
|
||||
"""
|
||||
@@ -329,12 +357,17 @@ class AdbcHook(DbApiHook):
|
||||
|
||||
with closing(conn.cursor()) as cur:
|
||||
execute_batch = self._resolve_execute_batch(
|
||||
cur, executemany=executemany, fast_executemany=fast_executemany
|
||||
cur,
|
||||
table_name=table_name,
|
||||
schema_name=schema_name,
|
||||
sql=sql,
|
||||
replace=replace,
|
||||
fast_executemany=fast_executemany,
|
||||
)
|
||||
|
||||
for chunked_rows in chunked(rows, commit_every or None):
|
||||
batch = self._to_record_batch(rows=chunked_rows, schema=table_schema)
|
||||
execute_batch(cur, sql, batch)
|
||||
execute_batch(batch)
|
||||
|
||||
if not autocommit:
|
||||
conn.commit()
|
||||
|
||||
@@ -22,9 +22,9 @@ import logging
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from adbc_driver_manager import dbapi
|
||||
from adbc_driver_manager import AdbcStatusCode, dbapi
|
||||
from adbc_driver_manager.dbapi import Cursor
|
||||
from pyarrow import field, schema, string
|
||||
from pyarrow import RecordBatch, field, schema, string
|
||||
|
||||
from airflow.models import Connection
|
||||
from airflow.providers.apache.arrow.hooks.adbc import AdbcHook
|
||||
@@ -97,22 +97,121 @@ class TestAdbcHook:
|
||||
|
||||
assert result == [(10, 30), (20, 40)]
|
||||
|
||||
def test_insert_rows_uses_executemany_with_record_batch(self):
|
||||
"""insert_rows must call executemany with a RecordBatch — the Arrow-native fast path."""
|
||||
def test_insert_rows_uses_adbc_ingest_by_default(self):
|
||||
"""By default insert_rows must hand each RecordBatch to the driver's bulk ingest."""
|
||||
rows = [("a",), ("b",)]
|
||||
self.hook.insert_rows("table", rows)
|
||||
|
||||
assert self.cur.executemany.called
|
||||
batch = self.cur.executemany.call_args[0][1]
|
||||
self.cur.executemany.assert_not_called()
|
||||
self.cur.adbc_ingest.assert_called_once()
|
||||
args, kwargs = self.cur.adbc_ingest.call_args
|
||||
assert args[0] == "table"
|
||||
assert isinstance(args[1], RecordBatch)
|
||||
assert args[1].num_rows == 2
|
||||
assert kwargs == {"mode": "append", "db_schema_name": None}
|
||||
assert self.conn.commit.call_count == 1
|
||||
|
||||
def test_insert_rows_fast_executemany_false_uses_executemany(self):
|
||||
"""fast_executemany=False keeps the previous bind path (one INSERT bound to the batch)."""
|
||||
rows = [("a",), ("b",)]
|
||||
self.hook.insert_rows("table", rows, fast_executemany=False)
|
||||
|
||||
self.cur.adbc_ingest.assert_not_called()
|
||||
self.cur.executemany.assert_called_once()
|
||||
sql, batch = self.cur.executemany.call_args[0]
|
||||
assert sql.startswith("INSERT INTO table")
|
||||
assert batch.num_rows == 2
|
||||
assert self.conn.commit.call_count >= 1
|
||||
|
||||
@mock.patch.object(AdbcHook, "_generate_insert_sql", return_value="REPLACE INTO table (col) VALUES ($1)")
|
||||
def test_insert_rows_replace_binds_even_with_fast_executemany(self, generate_insert_sql):
|
||||
"""replace=True cannot go through bulk ingest, so it must bind to the generated statement."""
|
||||
rows = [("a",), ("b",)]
|
||||
self.hook.insert_rows("table", rows, replace=True, fast_executemany=True)
|
||||
|
||||
assert generate_insert_sql.call_args[0][3] is True
|
||||
self.cur.adbc_ingest.assert_not_called()
|
||||
self.cur.executemany.assert_called_once()
|
||||
assert self.cur.executemany.call_args[0][0] == "REPLACE INTO table (col) VALUES ($1)"
|
||||
|
||||
@mock.patch.object(AdbcHook, "_generate_insert_sql", return_value="REPLACE INTO table (col) VALUES ($1)")
|
||||
def test_insert_rows_replace_with_fast_executemany_logs_debug_not_info(self, _generate_insert_sql):
|
||||
"""The caller did not ask for ingest explicitly, so binding for replace=True is only a debug note."""
|
||||
self.hook._log = mock.MagicMock(spec=logging.Logger)
|
||||
self.hook.insert_rows("table", [("a",)], replace=True)
|
||||
|
||||
self.hook._log.debug.assert_any_call(
|
||||
"Bulk ingest cannot replace rows; binding them to the statement instead"
|
||||
)
|
||||
info_messages = [c.args[0] for c in self.hook._log.info.call_args_list]
|
||||
assert not any("cannot replace" in m for m in info_messages)
|
||||
|
||||
def test_insert_rows_falls_back_to_executemany_when_ingest_not_supported(self):
|
||||
"""A driver without bulk ingest must fall back to binding, once, for every remaining batch."""
|
||||
self.cur.adbc_ingest.side_effect = dbapi.NotSupportedError("NOT_IMPLEMENTED: ingest")
|
||||
self.hook._log = mock.MagicMock(spec=logging.Logger)
|
||||
rows = [("a",), ("b",), ("c",)]
|
||||
|
||||
self.hook.insert_rows("table", rows, commit_every=2)
|
||||
|
||||
self.cur.adbc_ingest.assert_called_once()
|
||||
assert self.cur.executemany.call_count == 2
|
||||
assert [c.args[0].startswith("INSERT INTO table") for c in self.cur.executemany.call_args_list] == [
|
||||
True,
|
||||
True,
|
||||
]
|
||||
assert [c.args[1].num_rows for c in self.cur.executemany.call_args_list] == [2, 1]
|
||||
assert self.conn.commit.call_count == 2
|
||||
fallback_logs = [
|
||||
c for c in self.hook._log.info.call_args_list if "does not support bulk ingest" in c.args[0]
|
||||
]
|
||||
assert len(fallback_logs) == 1
|
||||
|
||||
def test_insert_rows_ingest_error_after_first_batch_is_not_swallowed(self):
|
||||
"""Only an unsupported first ingest falls back; later NotSupportedErrors propagate."""
|
||||
self.cur.adbc_ingest.side_effect = [None, dbapi.NotSupportedError("boom")]
|
||||
|
||||
with pytest.raises(dbapi.NotSupportedError, match="boom"):
|
||||
self.hook.insert_rows("table", [("a",), ("b",)], commit_every=1)
|
||||
|
||||
self.cur.executemany.assert_not_called()
|
||||
|
||||
def test_insert_rows_ingest_other_errors_propagate(self):
|
||||
"""Errors other than NotSupportedError from ingest are not hidden by the fallback."""
|
||||
self.cur.adbc_ingest.side_effect = dbapi.ProgrammingError(
|
||||
"bad table", status_code=AdbcStatusCode.INVALID_ARGUMENT
|
||||
)
|
||||
|
||||
with pytest.raises(dbapi.ProgrammingError, match="bad table"):
|
||||
self.hook.insert_rows("table", [("a",)])
|
||||
|
||||
self.cur.executemany.assert_not_called()
|
||||
|
||||
def test_insert_rows_schema_qualified_table_uses_db_schema_name(self):
|
||||
"""``schema.table`` must be split into the ingest target table and db_schema_name."""
|
||||
rows = [("a",)]
|
||||
self.hook.insert_rows("my_schema.my_table", rows)
|
||||
|
||||
self.conn.adbc_get_table_schema.assert_called_once_with(
|
||||
table_name="my_table", db_schema_filter="my_schema"
|
||||
)
|
||||
args, kwargs = self.cur.adbc_ingest.call_args
|
||||
assert args[0] == "my_table"
|
||||
assert kwargs == {"mode": "append", "db_schema_name": "my_schema"}
|
||||
|
||||
def test_insert_rows_commit_every_chunks_ingest(self):
|
||||
"""Chunking and per-chunk commits must be the same on the ingest path."""
|
||||
rows = [("a",), ("b",), ("c",)]
|
||||
self.hook.insert_rows("table", rows, commit_every=2)
|
||||
|
||||
assert [c.args[1].num_rows for c in self.cur.adbc_ingest.call_args_list] == [2, 1]
|
||||
assert self.conn.commit.call_count == 2
|
||||
|
||||
def test_insert_rows_autocommit_skips_explicit_commit(self):
|
||||
"""With autocommit=True the chunk loop must not call conn.commit() — drivers raise on it."""
|
||||
rows = [("a",), ("b",)]
|
||||
self.hook.insert_rows("table", rows, autocommit=True)
|
||||
|
||||
assert self.cur.executemany.called
|
||||
assert self.cur.adbc_ingest.called
|
||||
assert self.conn.commit.call_count == 0
|
||||
|
||||
def test_set_autocommit_applies_adbc_option(self):
|
||||
@@ -132,36 +231,13 @@ class TestAdbcHook:
|
||||
rows = [("a",), ("b",), ("c",)]
|
||||
self.hook.insert_rows("table", rows, commit_every=0)
|
||||
|
||||
assert self.cur.executemany.called
|
||||
batch = self.cur.executemany.call_args[0][1]
|
||||
assert self.cur.adbc_ingest.call_count == 1
|
||||
batch = self.cur.adbc_ingest.call_args[0][1]
|
||||
assert batch.num_rows == 3
|
||||
assert self.conn.commit.call_count == 1
|
||||
|
||||
def test_insert_rows_fast_executemany_not_supported(self):
|
||||
# Cursor that doesn't support setting fast_executemany
|
||||
class NoFastExecCursor(mock.MagicMock):
|
||||
def __setattr__(self, name, value):
|
||||
if name == "fast_executemany":
|
||||
raise AttributeError("fast_executemany not supported")
|
||||
super().__setattr__(name, value)
|
||||
|
||||
cur = NoFastExecCursor(spec=Cursor)
|
||||
conn = mock.MagicMock()
|
||||
conn.cursor.return_value = cur
|
||||
conn.adbc_get_table_schema.return_value = schema([field("col", string())])
|
||||
conn.extra_dejson = {}
|
||||
hook = self.make_hook_for_conn(conn)
|
||||
|
||||
table = "table"
|
||||
rows = [("x",), ("y",)]
|
||||
|
||||
hook.insert_rows(table, rows, executemany=True, fast_executemany=True)
|
||||
|
||||
assert cur.executemany.called
|
||||
assert conn.commit.call_count >= 1
|
||||
|
||||
def test_insert_rows_fast_executemany_supported(self):
|
||||
# Cursor that supports setting fast_executemany
|
||||
def test_insert_rows_does_not_set_fast_executemany_on_cursor(self):
|
||||
"""ADBC cursors have no fast_executemany attribute; the flag selects the path instead."""
|
||||
cur = mock.MagicMock(spec=Cursor)
|
||||
conn = mock.MagicMock()
|
||||
conn.cursor.return_value = cur
|
||||
@@ -169,14 +245,11 @@ class TestAdbcHook:
|
||||
conn.extra_dejson = {}
|
||||
hook = self.make_hook_for_conn(conn)
|
||||
|
||||
table = "table"
|
||||
rows = [("x",), ("y",)]
|
||||
hook.insert_rows("table", [("x",), ("y",)], executemany=True, fast_executemany=True)
|
||||
|
||||
hook.insert_rows(table, rows, executemany=True, fast_executemany=True)
|
||||
|
||||
assert cur.fast_executemany
|
||||
assert cur.executemany.called
|
||||
assert conn.commit.call_count >= 1
|
||||
assert not hasattr(cur, "fast_executemany")
|
||||
assert cur.adbc_ingest.called
|
||||
assert not cur.executemany.called
|
||||
|
||||
def test_insert_rows_target_fields_order_preserved(self):
|
||||
"""Schema must follow target_fields order so SQL and RecordBatch agree."""
|
||||
@@ -196,8 +269,8 @@ class TestAdbcHook:
|
||||
rows = [("Smith", "Alice"), ("Jones", "Bob")]
|
||||
hook.insert_rows("t", rows, target_fields=["last", "first"])
|
||||
|
||||
assert cur.executemany.called
|
||||
batch = cur.executemany.call_args[0][1] # second positional arg is the RecordBatch
|
||||
assert cur.adbc_ingest.called
|
||||
batch = cur.adbc_ingest.call_args[0][1] # second positional arg is the RecordBatch
|
||||
# Schema order must match target_fields, not the table declaration.
|
||||
assert batch.schema.names == ["last", "first"]
|
||||
# Values must land in the declared column order.
|
||||
@@ -225,6 +298,36 @@ class TestAdbcHook:
|
||||
with AdbcHook()._create_autocommit_connection() as adbc_conn:
|
||||
assert isinstance(adbc_conn, dbapi.Connection)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
importlib.util.find_spec("adbc_driver_sqlite") is None,
|
||||
reason="adbc_driver_sqlite not installed",
|
||||
)
|
||||
@pytest.mark.parametrize("fast_executemany", [True, False])
|
||||
def test_insert_rows_sqlite_round_trip(self, create_connection_without_db, tmp_path, fast_executemany):
|
||||
"""Both load paths must write the same rows, including a target_fields subset."""
|
||||
create_connection_without_db(
|
||||
Connection(
|
||||
conn_id="adbc_default",
|
||||
conn_type="adbc",
|
||||
extra=json.dumps({"driver": "adbc_driver_sqlite"}),
|
||||
)
|
||||
)
|
||||
hook = AdbcHook()
|
||||
# A file database, so the rows outlive the connection that inserted them.
|
||||
with mock.patch.object(AdbcHook, "uri", str(tmp_path / "db.sqlite")):
|
||||
# SQLite reports column types from the stored values, so an empty table only
|
||||
# yields a usable schema for integer columns.
|
||||
hook.run("CREATE TABLE t (id INTEGER, qty INTEGER, note TEXT DEFAULT 'dflt')", autocommit=True)
|
||||
|
||||
hook.insert_rows(
|
||||
"t", [(10, 1), (20, 2)], target_fields=["qty", "id"], fast_executemany=fast_executemany
|
||||
)
|
||||
|
||||
assert hook.get_records("SELECT id, qty, note FROM t ORDER BY id") == [
|
||||
(1, 10, "dflt"),
|
||||
(2, 20, "dflt"),
|
||||
]
|
||||
|
||||
@pytest.mark.skipif(
|
||||
importlib.util.find_spec("adbc_driver_sqlite") is None,
|
||||
reason="adbc_driver_sqlite not installed",
|
||||
|
||||
Reference in new issue
Block a user