dabla commented on code in PR #65618:
URL: https://github.com/apache/airflow/pull/65618#discussion_r4015589898
##########
providers/common/sql/src/airflow/providers/common/sql/hooks/sql.pyi:
##########
@@ -185,6 +186,27 @@ class DbApiHook(BaseHook):
@staticmethod
def get_openlineage_authority_part(connection, default_port: int | None =
None) -> str: ...
def get_db_log_messages(self, conn) -> None: ...
+ async def aget_conn(self) -> Any: ...
+ @overload
+ async def arun(
+ self,
+ sql: str | Iterable[str],
+ autocommit: bool = ...,
+ parameters: Iterable | Mapping[str, Any] | None = ...,
+ handler: None = ...,
+ split_statements: bool = ...,
+ return_last: bool = ...,
+ ) -> None: ...
+ @overload
+ async def arun(
+ self,
+ sql: str | Iterable[str],
+ autocommit: bool = ...,
+ parameters: Iterable | Mapping[str, Any] | None = ...,
Review Comment:
**[blocker]** `arun()` type stub is out of sync with the implementation —
missing `read_only`.
In `hooks/sql.py`, both `@overload` signatures of `arun` include `read_only:
bool = ...`, and the trigger calls it with that keyword (`triggers/sql.py`:
`await hook.arun(..., read_only=self.read_only)`). The `.pyi` stub (the
declared "public interface" per its own module docstring) only kept `sql`,
`autocommit`, `parameters`, `handler`, `split_statements`, `return_last` —
`read_only` was dropped from both overloads.
Since this package ships `.pyi` stubs that mypy resolves in preference to
the real implementation, any caller passing `read_only=` (including this PR's
own trigger) will fail static type checking (`breeze run mypy path/to/code` /
the `mypy-shared-*`-style CI hooks for providers). Please add `read_only: bool
= ...,` to both overloads in the stub, matching `hooks/sql.py`.
##########
providers/common/sql/src/airflow/providers/common/sql/hooks/sql.py:
##########
@@ -1171,3 +1185,220 @@ def get_db_log_messages(self, conn) -> None:
:param conn: Connection object
"""
+
+ def _translate_sql(self, sql: str) -> str:
+ """
+ Translate SQL to driver-specific paramstyle.
+
+ DB-specific hooks may override this to translate from a canonical style
+ to their driver's paramstyle if you want a unified SQL authoring style.
+ """
+ return sql
+
+ def _prepare_parameters(self, parameters: Iterable | Mapping[str, Any] |
None):
+ """DB hooks may override to adapt parameter style."""
+ return parameters
+
+ def _build_conn_kwargs_from_airflow_connection(self, db) -> dict:
+ """Build a DB-agnostic kwargs dict."""
+ extra = {}
+ extra_dejson = getattr(db, "extra_dejson", None)
+ if isinstance(extra_dejson, dict):
+ extra = extra_dejson
+ try:
+ port = int(db.port) if db.port else None
+ except (TypeError, ValueError):
+ port = None
+ return {
+ "host": db.host or "",
+ "port": port,
+ "username": db.login or "",
+ "password": db.password or "",
+ "database": extra.get("database") or extra.get("dbname") or "",
+ "schema": db.schema or "",
+ "conn_type": db.conn_type,
+ "extra": extra,
+ "raw_connection": db,
+ }
+
+ async def aget_conn(self) -> Any:
+ if not hasattr(self, "_conn_lock"):
+ self._conn_lock = asyncio.Lock()
+ async with self._conn_lock:
+ if not self._connection:
+ self._connection = await
get_async_connection(self.get_conn_id())
+ db = self._connection
+ if self.connector is None:
+ raise RuntimeError(f"{type(self).__name__} didn't have
`self.connector` set!")
+ conn_kwargs = self._build_conn_kwargs_from_airflow_connection(db)
+ return await self.connector.connect(**conn_kwargs)
+
+ async def _call(self, func: Callable, *args, **kwargs) -> Any:
+ if inspect.iscoroutinefunction(func):
+ return await func(*args, **kwargs)
+ return await sync_to_async(func)(*args, **kwargs)
+
+ @asynccontextmanager
+ async def _acreate_autocommit_connection(self, autocommit: bool = False):
+ conn = await self.aget_conn()
+ try:
+ if self.supports_autocommit:
+ set_autocommit = getattr(conn, "set_autocommit", None)
+ if set_autocommit and
inspect.iscoroutinefunction(set_autocommit):
+ await set_autocommit(autocommit)
+ else:
+ self.set_autocommit(conn, autocommit)
+ yield conn
+ finally:
+ close = getattr(conn, "aclose", None) or getattr(conn, "close",
None)
+ if close:
+ await self._call(close)
+
+ @asynccontextmanager
+ async def _aget_cursor(self, conn):
+ cursor = getattr(conn, "cursor", None)
+ if cursor is None:
+ raise TypeError(
+ f"{type(conn).__name__} has no cursor() method. "
+ "Override _aget_cursor in the DB-specific hook."
+ )
+ cur_or_cm = cursor()
+ if inspect.isawaitable(cur_or_cm):
+ cur_or_cm = await cur_or_cm
+ if hasattr(cur_or_cm, "__aenter__") and hasattr(cur_or_cm,
"__aexit__"):
+ async with cur_or_cm as cur:
+ yield cur
+ return
+ execute = getattr(cur_or_cm, "execute", None)
+ if execute and inspect.iscoroutinefunction(execute):
+ try:
+ yield cur_or_cm
+ finally:
+ close = getattr(cur_or_cm, "aclose", None) or
getattr(cur_or_cm, "close", None)
+ if close:
+ if inspect.iscoroutinefunction(close):
+ await close()
+ else:
+ close()
+ return
+ raise RuntimeError(
+ f"Unsupported cursor type returned by {type(conn).__name__}. "
+ "Override _aget_cursor in the DB-specific hook."
+ )
+
+ def supports_async_execution(self) -> bool:
Review Comment:
**[warning]** Capability detection should be an explicit override, not
implicit reflection on method identity.
```python
def supports_async_execution(self) -> bool:
"""Whether the DB-specific hook overrides :meth:`aget_conn` for a real
async driver."""
return type(self).aget_conn is not DbApiHook.aget_conn
def supports_readonly_execution(self) -> bool:
"""Whether the DB-specific hook overrides :meth:`_aenter_read_only`."""
return type(self)._aenter_read_only is not DbApiHook._aenter_read_only
```
Comparing `type(self).aget_conn is not DbApiHook.aget_conn` infers
capability from *whether a method was overridden*, rather than a hook stating
its capability directly. This is fragile: it silently breaks for any
indirection between the subclass and `DbApiHook` — a mixin providing
`aget_conn`, a decorator wrapping the method, `functools.wraps`-based
instrumentation, or simply an intermediate abstract base between `DbApiHook`
and the concrete hook — any of these makes the identity check `True` even when
the override doesn't actually provide a working async driver, or `False` when
it does. It also makes the contract implicit: a hook author has to know this
reflection trick exists rather than just declaring "I support async."
`DbApiHook` already has the right pattern a few lines above
(`hooks/sql.py:170-173`) for exactly this kind of capability negotiation —
plain class attributes, defaulting to `False`, that a DB-specific hook
overrides:
```python
# Override if this db supports autocommit.
supports_autocommit = False
# Override if this db supports executemany.
supports_executemany = False
```
`supports_async_execution`/`supports_readonly_execution` should follow the
same convention instead of introducing a different, reflection-based mechanism
for the same kind of question:
```python
# DbApiHook
# Override if this db supports a real async driver (aget_conn/_aget_cursor).
supports_async_execution = False
# Override if this db supports a read-only transaction (_aenter_read_only).
supports_readonly_execution = False
# PostgresHook
supports_async_execution = True
supports_readonly_execution = True
```
This is simpler, is robust regardless of how the subclass structures its
overrides (mixins, decorators, indirection), and keeps all of `DbApiHook`'s
capability flags consistent with one pattern instead of two.
##########
providers/common/sql/src/airflow/providers/common/sql/triggers/sql.py:
##########
@@ -57,49 +84,160 @@ def serialize(self) -> tuple[str, dict[str, Any]]:
{
"sql": self.sql,
"conn_id": self.conn_id,
+ "autocommit": self.autocommit,
+ "parameters": self.parameters,
+ "fetch_results": self.fetch_results,
+ "split_statements": self.split_statements,
+ "return_last": self.return_last,
+ "read_only": self.read_only,
"hook_params": self.hook_params,
},
)
- def get_hook(self) -> DbApiHook:
+ @staticmethod
+ def _jsonsafe_descriptions(
+ descriptions: list[Sequence[Sequence] | None],
+ ) -> list[list[list[Any]] | None]:
+ """
+ Normalise cursor descriptions into a JSON-serializable form for the
``TriggerEvent``.
+
+ ``cursor.description`` is a sequence of column 7-tuples whose
``type_code`` can be a
+ driver-specific object that is not JSON-serializable; such values are
stringified while
+ JSON-native fields (column names, sizes, precision, ...) are preserved.
+ """
+ safe: list[list[list[Any]] | None] = []
+ for description in descriptions:
+ if description is None:
+ safe.append(None)
+ continue
+ safe.append(
+ [
+ [
+ field if isinstance(field, (str, int, float, bool,
type(None))) else str(field)
+ for field in column
+ ]
+ for column in description
+ ]
+ )
+ return safe
+
+ async def aget_hook(self) -> DbApiHook:
"""
Return DbApiHook.
:return: DbApiHook for this connection
"""
- connection = BaseHook.get_connection(self.conn_id)
- hook = connection.get_hook(hook_params=self.hook_params)
+ hook = await get_async_hook(self.conn_id, hook_params=self.hook_params)
if not isinstance(hook, DbApiHook):
- raise AirflowException(
- f"You are trying to use `common-sql` with
{hook.__class__.__name__},"
- " but its provider does not support it. Please upgrade the
provider to a version that"
- " supports `common-sql`. The hook class should be a subclass
of"
- f" `{hook.__class__.__module__}.{hook.__class__.__name__}`."
- f" Got {hook.__class__.__name__} hook with class hierarchy:
{hook.__class__.mro()}"
+ raise TypeError(
+ f"You are trying to use the SqlExecuteQueryOperator in
deferrable mode with {hook.__class__.__name__},"
+ " but its provider does not support this. Please set
deferrable=False"
+ f" Got {hook.__class__.__name__} with class hierarchy:
{hook.__class__.mro()}"
+ )
+ if self.read_only and not (hook.supports_async_execution() and
hook.supports_readonly_execution()):
+ raise NotImplementedError(
+ f"{hook.__class__.__name__} does not support read-only
execution, so it cannot run a"
+ " deferred query safely (a triggerer restart could re-run it).
Set"
+ " enforce_read_only=False to run without the read-only guard
if the query is"
+ " idempotent, or deferrable=False to run it on the worker."
)
return hook
- async def _get_records(self) -> Any:
- from asgiref.sync import sync_to_async
-
- hook = self.get_hook()
+ async def _run_query(self, hook: DbApiHook, fetch_results: bool):
+ """
+ Run the query against ``hook``, using its native async driver if
available.
+ Hooks without a real async driver
(:meth:`DbApiHook.supports_async_execution`) fall back to
+ running the synchronous :meth:`DbApiHook.run` in a worker thread,
matching the compatibility the
+ `GenericTransfer` operator has always relied on for arbitrary
DB-specific hooks.
+ """
+ if hook.supports_async_execution():
+ if fetch_results:
+ return await hook.arun(
+ sql=self.sql,
+ autocommit=self.autocommit,
+ parameters=self.parameters,
+ handler=fetch_all_handler,
+ split_statements=self.split_statements,
+ return_last=self.return_last,
+ read_only=self.read_only,
+ )
+ return await hook.arun(
+ sql=self.sql,
+ autocommit=self.autocommit,
+ parameters=self.parameters,
+ handler=None,
+ split_statements=self.split_statements,
+ return_last=self.return_last,
+ read_only=self.read_only,
+ )
if AIRFLOW_V_3_2_PLUS:
# This is only supported from Airflow 3.2 or higher due to added
async support in CommsDecoder
- return await sync_to_async(hook.get_records)(self.sql)
- return hook.get_records(self.sql)
+ # `sync_to_async` erases `run`'s overloads, so it is cast to a
plain callable first.
+ arun_in_thread = sync_to_async(cast("Callable[..., Any]",
hook.run))
+ if fetch_results:
+ return await arun_in_thread(
+ sql=self.sql,
+ autocommit=self.autocommit,
+ parameters=self.parameters,
+ handler=fetch_all_handler,
+ split_statements=self.split_statements,
+ return_last=self.return_last,
+ )
+ return await arun_in_thread(
+ sql=self.sql,
+ autocommit=self.autocommit,
+ parameters=self.parameters,
+ handler=None,
+ split_statements=self.split_statements,
+ return_last=self.return_last,
+ )
+ if fetch_results:
+ return hook.run(
+ sql=self.sql,
+ autocommit=self.autocommit,
+ parameters=self.parameters,
+ handler=fetch_all_handler,
+ split_statements=self.split_statements,
+ return_last=self.return_last,
+ )
+ return hook.run(
+ sql=self.sql,
+ autocommit=self.autocommit,
+ parameters=self.parameters,
+ handler=None,
+ split_statements=self.split_statements,
+ return_last=self.return_last,
+ )
async def run(self) -> AsyncIterator[TriggerEvent]:
try:
+ hook = await self.aget_hook()
+
self.log.info("Extracting data from %s", self.conn_id)
self.log.info("Executing: \n %s", self.sql)
- self.log.info("Reading records from %s", self.conn_id)
- results = await self._get_records()
+ if self.fetch_results:
+ # Fetch the raw rows with the built-in handler and return them
with the cursor
+ # descriptions; the operator applies any user handler on the
worker.
+ results = await self._run_query(hook, fetch_results=True)
+
+ self.log.info("Executing query from %s done!", self.conn_id)
+ self.log.debug("results: %s", results)
+ yield TriggerEvent(
+ {
+ "status": "success",
+ "results": results,
+ "descriptions":
self._jsonsafe_descriptions(hook.descriptions),
+ }
+ )
+
+ else:
+ await self._run_query(hook, fetch_results=False)
+
+ self.log.info("Executing query from %s done!", self.conn_id)
Review Comment:
**[blocker]** Unbounded result set is written to the metadata database, with
no way to bound or detect it.
The rationale for replaying results via `_ReplayCursor` instead of running
the user `handler` directly in the triggerer is sound in principle: the
triggerer must not execute arbitrary user code (it has no DAG bundle access and
the Security Model keeps user code off that process), so the built-in
`fetch_all_handler` runs in the triggerer and the user's `handler` is deferred
to the worker in `execute_complete`.
However, the mechanism for getting the rows there is not an in-memory hop —
`yield TriggerEvent({"results": ..., "descriptions": ...})` is consumed by the
scheduler and persisted into `TaskInstance.next_kwargs`, a JSON column on the
metadata database (`airflow-core/src/airflow/models/taskinstance.py:672,1864`),
until the worker picks the task instance back up and calls `execute_complete`.
Every row of every deferred query is serialized to JSON and written as a
task-instance row in Airflow's own database, then read back out — unlike the
non-deferrable path, which streams rows straight from the DB-API cursor without
ever touching Airflow's metadata store.
The docstring's guidance ("very large results are best fetched with
deferrable=False") is not enforced anywhere, and there is no mechanism in this
PR for a user or the operator to determine in advance whether a given query's
result set is "large" — the row/byte count is only known after the query has
already run and been serialized into `next_kwargs`. A single deferred task with
an unexpectedly large result set writes that entire payload into the shared
metadata database that the scheduler, webserver, and every other task instance
also depend on, which is a shared-resource risk (bloated table/row size, backup
size, replication lag, storage pressure), not merely a per-task inefficiency.
This is a design gap, not a documentation gap: relying on docstring guidance
alone is not a safeguard against a task instance writing an arbitrarily large
payload into the metadata database.
A general fix would parallel how workers get a customizable XCom backend to
keep large payloads out of the metadata database — the trigger path currently
has no equivalent, so `TriggerEvent` payloads always go through the metadata
database regardless of size. That is a broader architectural change and out of
scope for this PR, but this PR introduces the first deferred code path in
`common-sql` that can put arbitrarily large query result sets through that
channel, and currently has no limit, no size check, and no fallback for the
case where the result set turns out to be large.
##########
providers/common/sql/src/airflow/providers/common/sql/operators/generic_transfer.py:
##########
@@ -206,7 +210,7 @@ def execute_complete(
event: dict[Any, Any] | None = None,
) -> Any:
if event:
- if event.get("status") == "failure":
+ if event.get("status") == "error":
Review Comment:
**[warning]** Avoidable breaking change to `SQLExecuteQueryTrigger`, and no
changelog/version-bump entry accompanies it.
`SQLExecuteQueryTrigger.__init__` dropped `**kwargs` and now requires
`autocommit`, `split_statements`, and `return_last` as required args with no
defaults, and the `TriggerEvent` status key changed from `"failure"` to
`"error"` (reflected here in `generic_transfer.py`'s `execute_complete`).
Neither change is required by the deferrable-execution feature itself:
- **Constructor:** the old trigger's implicit behavior was equivalent to
`autocommit=False, split_statements=False, return_last=True` (it just called
`hook.get_records(self.sql)`). Giving the three new params those same defaults
would let old serialized kwargs dicts (from tasks already deferred before an
upgrade) still reconstruct successfully via `cls(**kwargs)`, while new callers
can still pass them explicitly. As written, any `GenericTransferOperator` task
already deferred before the upgrade will fail to reconstruct its trigger after
upgrade with a missing-argument `TypeError`.
- **Status rename:** `"error"` is the dominant convention across other
providers' triggers (~409 occurrences vs. ~5 for `"failure"`), but the rename
is not required by this feature, and it breaks any code (including a
subclassed/custom operator) that checks `event["status"] == "failure"`.
Review thread 8/6 raised making this "purely additive", and the response was
to merge `GenericTransfer`'s trigger into `SQLExecuteQueryTrigger` so the class
name wouldn't change — but the constructor signature and status key still
changed underneath the same class name, so the break was moved rather than
removed. Defaulting the three new params and keeping (or aliasing) `"failure"`
would remove the breaking change entirely at no functional cost.
If the breaking change is kept regardless, there is still no corresponding
update to `providers/common/sql/docs/changelog.rst` (providers don't use
newsfragments — the changelog file is the right place per
`providers/AGENTS.md`) or a provider major-version bump reflected in this diff.
##########
providers/common/sql/src/airflow/providers/common/sql/hooks/sql.py:
##########
@@ -1171,3 +1185,220 @@ def get_db_log_messages(self, conn) -> None:
:param conn: Connection object
"""
+
+ def _translate_sql(self, sql: str) -> str:
+ """
+ Translate SQL to driver-specific paramstyle.
+
+ DB-specific hooks may override this to translate from a canonical style
+ to their driver's paramstyle if you want a unified SQL authoring style.
+ """
+ return sql
+
+ def _prepare_parameters(self, parameters: Iterable | Mapping[str, Any] |
None):
+ """DB hooks may override to adapt parameter style."""
+ return parameters
+
+ def _build_conn_kwargs_from_airflow_connection(self, db) -> dict:
+ """Build a DB-agnostic kwargs dict."""
+ extra = {}
+ extra_dejson = getattr(db, "extra_dejson", None)
+ if isinstance(extra_dejson, dict):
+ extra = extra_dejson
+ try:
+ port = int(db.port) if db.port else None
+ except (TypeError, ValueError):
+ port = None
+ return {
+ "host": db.host or "",
+ "port": port,
+ "username": db.login or "",
+ "password": db.password or "",
+ "database": extra.get("database") or extra.get("dbname") or "",
+ "schema": db.schema or "",
+ "conn_type": db.conn_type,
+ "extra": extra,
+ "raw_connection": db,
+ }
+
+ async def aget_conn(self) -> Any:
+ if not hasattr(self, "_conn_lock"):
+ self._conn_lock = asyncio.Lock()
+ async with self._conn_lock:
+ if not self._connection:
+ self._connection = await
get_async_connection(self.get_conn_id())
+ db = self._connection
+ if self.connector is None:
+ raise RuntimeError(f"{type(self).__name__} didn't have
`self.connector` set!")
+ conn_kwargs = self._build_conn_kwargs_from_airflow_connection(db)
+ return await self.connector.connect(**conn_kwargs)
+
+ async def _call(self, func: Callable, *args, **kwargs) -> Any:
+ if inspect.iscoroutinefunction(func):
+ return await func(*args, **kwargs)
+ return await sync_to_async(func)(*args, **kwargs)
+
+ @asynccontextmanager
+ async def _acreate_autocommit_connection(self, autocommit: bool = False):
Review Comment:
**[warning]** Connection close and autocommit handling guess at driver shape
via duck-typing instead of an explicit per-hook contract.
```python
@asynccontextmanager
async def _acreate_autocommit_connection(self, autocommit: bool = False):
conn = await self.aget_conn()
try:
if self.supports_autocommit:
set_autocommit = getattr(conn, "set_autocommit", None)
if set_autocommit and
inspect.iscoroutinefunction(set_autocommit):
await set_autocommit(autocommit)
else:
self.set_autocommit(conn, autocommit)
yield conn
finally:
close = getattr(conn, "aclose", None) or getattr(conn, "close", None)
if close:
await self._call(close)
```
There is no standard async equivalent of PEP 249 that all async DB drivers
share, so this code cannot assume a single connection-object shape. The current
implementation compensates with `getattr`/`inspect.iscoroutinefunction` probing
at the base-class level to guess whether close is named `close()` or
`aclose()`, and whether autocommit is a coroutine method on the connection or
must go through the hook's own synchronous `set_autocommit()`. This guessing is
itself unreliable: for the one driver this PR ships, `psycopg.AsyncConnection`
exposes `close()` as a coroutine but has no `aclose()` at all, so the
`aclose`-first check silently falls through to the correct branch only because
of the `or` fallback — a driver that has neither name, or that has a
synchronous `close()` alongside an unrelated async `aclose()`, would be
mishandled.
The read-only enforcement path in this same PR (`_aenter_read_only`) uses
the opposite, more robust pattern: the base class raises `NotImplementedError`
by default, and `PostgresHook` overrides it with driver-specific knowledge.
`_acreate_autocommit_connection` should follow that same contract instead of
guessing generically:
```python
# DbApiHook (base)
async def _aclose_conn(self, conn) -> None:
raise NotImplementedError(f"{type(self).__name__} does not implement
_aclose_conn.")
async def _aset_autocommit(self, conn, autocommit: bool) -> None:
raise NotImplementedError(f"{type(self).__name__} does not implement
_aset_autocommit.")
@asynccontextmanager
async def _acreate_autocommit_connection(self, autocommit: bool = False):
conn = await self.aget_conn()
try:
if self.supports_autocommit:
await self._aset_autocommit(conn, autocommit)
yield conn
finally:
await self._aclose_conn(conn)
# PostgresHook
async def _aclose_conn(self, conn) -> None:
await conn.close()
async def _aset_autocommit(self, conn, autocommit: bool) -> None:
await conn.set_autocommit(autocommit)
```
This states the actual driver contract explicitly per hook, the same fix as
comment [3]'s capability flags — one consistent override pattern instead of
base-class code inferring behavior it cannot know generically.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]