This is an automated email from the ASF dual-hosted git repository.

dabla pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new d0577848b68 Fix DeadlockImminentError when a connection is resolved 
inside an async task (#71890)
d0577848b68 is described below

commit d0577848b68b37ad01418bb1c61dce605a435bcd
Author: David Blain <[email protected]>
AuthorDate: Thu Sep 10 17:53:27 2026 +0200

    Fix DeadlockImminentError when a connection is resolved inside an async 
task (#71890)
    
    * Add aget_uri and aextra_dejson to Connection to fix DeadlockImminentError 
in async tasks
    
    get_uri() accesses extra_dejson, which calls the synchronous mask_secret() →
    comms.send() from within the event-loop thread. Any async hook or task that
    calls aget_hook() / aget_connection() triggers this path, and Airflow 
3.3.1's
    DeadlockImminentError detection surfaces the bug.
    
    Add aextra_dejson() — an async method that awaits amask_secret() instead of
    the blocking mask_secret() — and aget_uri(), which delegates URI assembly to
    a new shared _build_uri() helper and calls await self.aextra_dejson(). This
    keeps the entire connection-serialisation path safely on the async stack.
    
    The sync get_uri() / extra_dejson are unchanged; _build_uri() is the single
    source of truth for the URI format, shared by both paths.
    
    Co-authored-by: Copilot <[email protected]>
---
 .../ssh/tests/unit/ssh/hooks/test_ssh_async.py     |  8 ++--
 task-sdk/docs/deferred-vs-async-operators.rst      |  2 +-
 task-sdk/src/airflow/sdk/definitions/connection.py | 43 ++++++++++++++++--
 task-sdk/src/airflow/sdk/execution_time/context.py | 11 ++++-
 .../tests/task_sdk/definitions/test_connection.py  | 52 ++++++++++++++++++++++
 .../tests/task_sdk/execution_time/test_context.py  | 37 +++++++++++++++
 6 files changed, 144 insertions(+), 9 deletions(-)

diff --git a/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py 
b/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py
index 9001bf3ca7f..dbda9f5e0ab 100644
--- a/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py
+++ b/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py
@@ -135,9 +135,11 @@ class TestSSHHookAsync:
 
         mock_ssh_client = mock.AsyncMock()
 
-        with mock.patch("asgiref.sync.sync_to_async") as mock_sync:
-            mock_sync.return_value = mock.AsyncMock(return_value=mock_conn_obj)
-
+        with mock.patch(
+            "airflow.providers.ssh.hooks.ssh.get_async_connection",
+            new_callable=mock.AsyncMock,
+            return_value=mock_conn_obj,
+        ):
             with mock.patch("asyncssh.connect", new_callable=mock.AsyncMock) 
as mock_connect:
                 mock_connect.return_value = mock_ssh_client
                 result = await hook._get_conn()
diff --git a/task-sdk/docs/deferred-vs-async-operators.rst 
b/task-sdk/docs/deferred-vs-async-operators.rst
index 466060ab3a4..a2a0df16054 100644
--- a/task-sdk/docs/deferred-vs-async-operators.rst
+++ b/task-sdk/docs/deferred-vs-async-operators.rst
@@ -234,7 +234,7 @@ This allows multiple paginated requests to be performed 
efficiently within a sin
 
        @task
        async def get_users():
-           hook = KiotaRequestAdapterHook.get_hook(conn_id="msgraph_default")
+           hook = await 
KiotaRequestAdapterHook.aget_hook(conn_id="msgraph_default")
 
            return await hook.paginated_run(url="users")
 
diff --git a/task-sdk/src/airflow/sdk/definitions/connection.py 
b/task-sdk/src/airflow/sdk/definitions/connection.py
index 06a95a3b868..8e9ab232a7c 100644
--- a/task-sdk/src/airflow/sdk/definitions/connection.py
+++ b/task-sdk/src/airflow/sdk/definitions/connection.py
@@ -153,10 +153,13 @@ class Connection:
         else:
             self.__dict__.update(attrs.asdict(self.from_uri(uri, 
conn_id=conn_id), recurse=False))
 
-    def get_uri(self) -> str:
-        """Generate and return connection in URI format."""
-        from urllib.parse import parse_qsl
+    def _build_uri(self, extra_dejson: dict) -> str:
+        """
+        Build the connection URI given a pre-resolved extra_dejson dict.
 
+        Shared by ``get_uri`` (sync) and ``aget_uri`` (async) so the
+        URI-assembly logic lives in exactly one place.
+        """
         if self.conn_type:
             uri = f"{self.conn_type.lower().replace('_', '-')}://"
         else:
@@ -202,7 +205,6 @@ class Connection:
 
         if self.extra:
             try:
-                extra_dejson = self.extra_dejson
                 query: str | None = urlencode(extra_dejson)
             except TypeError:
                 query = None
@@ -213,6 +215,20 @@ class Connection:
 
         return uri
 
+    def get_uri(self) -> str:
+        """Generate and return connection in URI format."""
+        return self._build_uri(self.extra_dejson)
+
+    async def aget_uri(self) -> str:
+        """
+        Async version of ``get_uri``, safe for use inside an async task.
+
+        Calls ``aextra_dejson`` so that secret masking uses ``asend()``
+        instead of the synchronous ``send()``, preventing
+        ``DeadlockImminentError`` when invoked from within an async context.
+        """
+        return self._build_uri(await self.aextra_dejson())
+
     def get_hook(self, *, hook_params=None):
         """Return hook based on conn_type."""
         from airflow.sdk._shared.module_loading import import_string
@@ -306,6 +322,25 @@ class Connection:
 
         return extra
 
+    async def aextra_dejson(self) -> dict:
+        """
+        Async version of ``extra_dejson``, safe for use inside an async task.
+
+        Uses ``amask_secret`` instead of the synchronous ``mask_secret``, so 
calling
+        this from within an async context does not trigger 
``DeadlockImminentError``.
+        """
+        from airflow.sdk.log import amask_secret
+
+        extra: dict = {}
+        if self.extra:
+            try:
+                extra = json.loads(self.extra)
+            except JSONDecodeError:
+                log.exception("Failed to deserialize extra property `extra`, 
returning empty dictionary")
+            else:
+                await amask_secret(extra)
+        return extra
+
     def get_extra_dejson(self) -> dict:
         """Deserialize extra property to JSON."""
         import warnings
diff --git a/task-sdk/src/airflow/sdk/execution_time/context.py 
b/task-sdk/src/airflow/sdk/execution_time/context.py
index f23179a50d6..5a14238b088 100644
--- a/task-sdk/src/airflow/sdk/execution_time/context.py
+++ b/task-sdk/src/airflow/sdk/execution_time/context.py
@@ -297,7 +297,16 @@ async def _async_get_connection(conn_id: str) -> 
Connection:
                 conn = await 
sync_to_async(secrets_backend.get_connection)(conn_id)  # type: 
ignore[assignment]
 
             if conn:
-                SecretCache.save_connection_uri(conn_id, conn.get_uri())
+                # Use aget_uri if the returned connection object supports it 
(the SDK's own
+                # Connection class does); otherwise fall back to the sync 
get_uri, since backends
+                # can hand back other connection-shaped objects (e.g. 
MetastoreBackend returns
+                # airflow.models.Connection, which has no aget_uri).
+                aget_uri = getattr(conn, "aget_uri", None)
+                if aget_uri is not None:
+                    uri = await aget_uri()
+                else:
+                    uri = await sync_to_async(conn.get_uri)()
+                SecretCache.save_connection_uri(conn_id, uri)
                 await _amask_connection_secrets(conn)
                 return conn
         except AirflowSecretsBackendAccessDenied:
diff --git a/task-sdk/tests/task_sdk/definitions/test_connection.py 
b/task-sdk/tests/task_sdk/definitions/test_connection.py
index c973fc58c91..d471cccfcf6 100644
--- a/task-sdk/tests/task_sdk/definitions/test_connection.py
+++ b/task-sdk/tests/task_sdk/definitions/test_connection.py
@@ -234,6 +234,58 @@ class TestConnections:
         connection.extra = '{"auth": {"type": "oauth"}, "headers": 
{"User-Agent": "Airflow"}}'
         assert connection.extra_dejson == {"auth": {"type": "oauth"}, 
"headers": {"User-Agent": "Airflow"}}
 
+    @pytest.mark.asyncio
+    @mock.patch("airflow.sdk.definitions.connection.Connection.aextra_dejson")
+    async def test_aget_uri(self, mock_aextra_dejson):
+        """aget_uri must produce the same URI as get_uri and use 
aextra_dejson."""
+        extra = {"charset": "utf8", "timeout": "30"}
+        mock_aextra_dejson.return_value = extra
+
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="mysql",
+            host="localhost",
+            login="user",
+            password="password",
+            schema="test_schema",
+            port=3306,
+            extra='{"charset": "utf8", "timeout": "30"}',
+        )
+
+        uri = await conn.aget_uri()
+        assert uri == conn.get_uri()
+        mock_aextra_dejson.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_aextra_dejson_calls_amask_secret(self):
+        """aextra_dejson must use amask_secret (async), never the sync 
mask_secret."""
+        connection = Connection(
+            conn_id="test_conn",
+            conn_type="http",
+            extra='{"api_key": "secret"}',
+        )
+
+        with (
+            mock.patch("airflow.sdk.log.amask_secret") as mock_amask,
+            mock.patch("airflow.sdk.log.mask_secret") as mock_mask,
+        ):
+            result = await connection.aextra_dejson()
+
+        assert result == {"api_key": "secret"}
+        mock_amask.assert_awaited_once_with({"api_key": "secret"})
+        mock_mask.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_aextra_dejson_no_extra(self):
+        """aextra_dejson must return an empty dict without calling 
amask_secret when extra is None."""
+        connection = Connection(conn_id="test_conn", conn_type="http")
+
+        with mock.patch("airflow.sdk.log.amask_secret") as mock_amask:
+            result = await connection.aextra_dejson()
+
+        assert result == {}
+        mock_amask.assert_not_called()
+
 
 class TestConnectionsFromSecrets:
     def test_get_connection_secrets_backend(self, mock_supervisor_comms, 
tmp_path):
diff --git a/task-sdk/tests/task_sdk/execution_time/test_context.py 
b/task-sdk/tests/task_sdk/execution_time/test_context.py
index f28d8e9c3e5..cbc579c1f15 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_context.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_context.py
@@ -1272,6 +1272,43 @@ class TestAsyncGetConnection:
             mock_supervisor_comms.send.assert_not_called()
             mock_supervisor_comms.asend.assert_not_called()
 
+    @pytest.mark.asyncio
+    async def test_async_get_connection_uses_aget_uri_not_get_uri(self, 
mock_supervisor_comms):
+        """_async_get_connection must call aget_uri() when caching, never the 
sync get_uri().
+
+        get_uri() accesses extra_dejson which calls mask_secret() -> 
comms.send()
+        from the event-loop thread, triggering DeadlockImminentError in 
Airflow 3.3.1.
+        aget_uri() uses amask_secret() -> asend() and is safe in async 
contexts.
+        """
+        from airflow.sdk.execution_time.cache import SecretCache
+
+        sample_connection = Connection(
+            conn_id="test_conn",
+            conn_type="postgres",
+            host="localhost",
+            port=5432,
+            extra='{"sslmode": "require"}',
+        )
+
+        class MockSecretsBackend:
+            def get_connection(self, conn_id: str) -> Connection | None:
+                return sample_connection if conn_id == "test_conn" else None
+
+        with (
+            patch(
+                
"airflow.sdk.execution_time.supervisor.ensure_secrets_backend_loaded", 
autospec=True
+            ) as mock_load,
+            mock.patch.object(SecretCache, "save_connection_uri"),
+        ):
+            mock_load.return_value = [MockSecretsBackend()]
+
+            await _async_get_connection("test_conn")
+
+            # get_uri() would reach the sync mask_secret() -> comms.send(), 
which deadlocks
+            # on the event-loop thread; aget_uri() must go through 
amask_secret() -> comms.asend().
+            mock_supervisor_comms.send.assert_not_called()
+            mock_supervisor_comms.asend.assert_awaited()
+
 
 class TestSecretsBackend:
     """Test that connection resolution uses the backend chain correctly."""

Reply via email to