DG47 commented on code in PR #74279:
URL: https://github.com/apache/airflow/pull/74279#discussion_r4394034564


##########
providers/common/sql/src/airflow/providers/common/sql/hooks/sql.py:
##########
@@ -900,13 +902,23 @@ def _run_command(self, cur, sql_statement, parameters):
         if self.log_sql:
             self.log.info("Running statement: %s, parameters: %s", 
sql_statement, parameters)
 
-        if parameters:
-            # If we're using psycopg3, we might need to handle parameters 
differently
-            if hasattr(cur, "__module__") and "psycopg" in cur.__module__ and 
isinstance(parameters, list):
-                parameters = tuple(parameters)
-            cur.execute(sql_statement, parameters)
-        else:
-            cur.execute(sql_statement)
+        # Keep a reference to the cursor while the statement is in flight, so 
that ``cancel_query`` can
+        # reach it when the task is killed in the middle of a long-running 
statement.
+        self._running_cursor = cur
+        try:
+            if parameters:
+                # If we're using psycopg3, we might need to handle parameters 
differently
+                if (
+                    hasattr(cur, "__module__")
+                    and "psycopg" in cur.__module__
+                    and isinstance(parameters, list)
+                ):
+                    parameters = tuple(parameters)
+                cur.execute(sql_statement, parameters)
+            else:
+                cur.execute(sql_statement)
+        finally:
+            self._running_cursor = None

Review Comment:
   Thanks for running it, that's a real gap. Fixed in cd780893b4. 
`_run_command` and `run` now go through a `_track_running_cursor` context 
manager. When a non-`Exception` `BaseException` (`AirflowTaskTimeout`, 
`KeyboardInterrupt`) leaves the block, it calls `cancel_query()` while the 
cursor is still open and then re-raises. A failing cancel is logged and doesn't 
replace the timeout. `test_running_cursor_is_cleared_when_statement_fails` now 
only covers a plain `RuntimeError`, which doesn't cancel. The new 
`test_statement_is_cancelled_when_interrupted_by_timeout` makes `execute` (and 
`fetchall`) raise `AirflowTaskTimeout` and checks that `cursor.cancel()` went 
out. On the operator side, 
`test_execution_timeout_cancels_the_running_statement` covers the same thing 
through `execute()`. The `on_kill` docstring now says the timeout case is 
handled by the hook before `on_kill` runs.



##########
providers/common/sql/src/airflow/providers/common/sql/hooks/sql.py:
##########
@@ -918,6 +930,42 @@ def _run_command(self, cur, sql_statement, parameters):
         if (row_count := handlers.get_row_count(cur)) is not None:
             self.log.info("Rows affected: %s", row_count)
 
+    def cancel_query(self) -> bool:
+        """
+        Cancel the SQL statement that this hook is currently executing, if 
there is one.
+
+        
:class:`~airflow.providers.common.sql.operators.sql.SQLExecuteQueryOperator` 
calls this method from
+        ``on_kill`` when the task instance is killed while a statement is 
still running, so that the
+        database stops working on a query whose result nobody is waiting for 
anymore.
+
+        The default implementation relies on the cancellation extensions 
offered by many DB-API drivers:
+        ``cursor.cancel()`` is used when the cursor provides it, otherwise 
``connection.cancel()`` is used
+        when the cursor exposes its connection and the connection provides it 
(e.g. ``psycopg2``,

Review Comment:
   Agreed, thanks for checking with a real SIGTERM. In cd780893b4 psycopg2 is 
gone from the list (it's now `psycopg`, `oracledb`, `trino`). The docstring and 
`operators.rst` now say this only helps with drivers that return to Python to 
handle signals during a statement, and name psycopg2 as one that doesn't. I'll 
update the PR description too.



##########
providers/common/sql/src/airflow/providers/common/sql/hooks/sql.py:
##########
@@ -900,13 +902,23 @@ def _run_command(self, cur, sql_statement, parameters):
         if self.log_sql:
             self.log.info("Running statement: %s, parameters: %s", 
sql_statement, parameters)
 
-        if parameters:
-            # If we're using psycopg3, we might need to handle parameters 
differently
-            if hasattr(cur, "__module__") and "psycopg" in cur.__module__ and 
isinstance(parameters, list):
-                parameters = tuple(parameters)
-            cur.execute(sql_statement, parameters)
-        else:
-            cur.execute(sql_statement)
+        # Keep a reference to the cursor while the statement is in flight, so 
that ``cancel_query`` can
+        # reach it when the task is killed in the middle of a long-running 
statement.
+        self._running_cursor = cur

Review Comment:
   Yes. In cd780893b4 `run()` keeps the cursor tracked from `_run_command` 
through `handler(cur)`, so a kill during `fetchall()` reaches 
`cursor.cancel()`. The tracking is re-entrant for the same cursor: 
`_run_command` still tracks on its own for hooks that call it directly (e.g. 
`DatabricksSqlHook.run`), and when `run()` already tracks the cursor it leaves 
the clearing to `run()`. Covered by `test_cancel_while_handler_fetches_results` 
and by the `fetchall` case of the timeout test.



##########
providers/common/sql/src/airflow/providers/common/sql/hooks/sql.py:
##########
@@ -918,6 +930,42 @@ def _run_command(self, cur, sql_statement, parameters):
         if (row_count := handlers.get_row_count(cur)) is not None:
             self.log.info("Rows affected: %s", row_count)
 
+    def cancel_query(self) -> bool:
+        """
+        Cancel the SQL statement that this hook is currently executing, if 
there is one.
+
+        
:class:`~airflow.providers.common.sql.operators.sql.SQLExecuteQueryOperator` 
calls this method from
+        ``on_kill`` when the task instance is killed while a statement is 
still running, so that the
+        database stops working on a query whose result nobody is waiting for 
anymore.
+
+        The default implementation relies on the cancellation extensions 
offered by many DB-API drivers:
+        ``cursor.cancel()`` is used when the cursor provides it, otherwise 
``connection.cancel()`` is used
+        when the cursor exposes its connection and the connection provides it 
(e.g. ``psycopg2``,
+        ``psycopg`` or ``oracledb``). Hooks for databases whose driver has no 
such extension can override
+        this method and cancel the statement in a database specific way, for 
example by issuing a
+        ``KILL QUERY`` command on a separate connection.
+
+        :return: ``True`` if a cancellation request was sent to the database, 
``False`` if no statement
+            is running or the driver does not support cancelling a running 
statement.
+        """
+        # getattr: a subclass that does not call ``DbApiHook.__init__`` should 
still get the no-op path
+        cur = getattr(self, "_running_cursor", None)
+        if cur is None:
+            self.log.info("No SQL statement is currently running, there is 
nothing to cancel.")

Review Comment:
   Done in cd780893b4: it's now `"No running SQL statement is tracked by %s, 
there is nothing to cancel."` at WARNING, with the hook class name. The 
`cancel_query` docstring also says that only statements run through `run` / 
`_run_command` are tracked, and `operators.rst` mentions hooks that run 
statements another way.



-- 
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]

Reply via email to