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 a67d43f2baf Fix SSHHookAsync deadlock on the extra and compress like
SSHHook (#76528)
a67d43f2baf is described below
commit a67d43f2baff85888d7323ce5d90e618f63a3701
Author: David Blain <[email protected]>
AuthorDate: Thu Nov 12 19:40:16 2026 +0100
Fix SSHHookAsync deadlock on the extra and compress like SSHHook (#76528)
* Fix SSHHookAsync deadlock on the extra and compress like SSHHook
Parsing the connection extra through extra_dejson masks its secrets with
a synchronous supervisor call, which raises DeadlockImminentError when
the hook opens a connection in an async task that has other calls in
flight on the same event loop. The connection itself was already fetched
asynchronously; the extra now is too.
The async hook also left compression to asyncssh, which prefers none,
while SSHHook compresses unless the compress extra is false. Bulk
transfers over the async hook were therefore slower than over the sync
one for no reason.
* Tighten SSHHookAsync extra tests and document compress side effects
- The asynchronous-extra test builds the connection with an empty extra,
so it only passes when the extra read through common.compat reaches
_parse_extras; patches are decorators with autospec on the compat
helpers (asyncssh.connect stays an AsyncMock, as autospec on an
@async_context_manager function is not awaitable).
- The _parse_extras tests pass the extra through one helper on a real
Connection instead of bare MagicMocks; the helper is renamed
_parse_extra_dict so it no longer shares the method's name.
- The compress check is spelled as in SSHHook.
- The compress docs note that the async hook ignores Compression from
~/.ssh/config and that each compressed connection holds zlib state.
* Correct SSHHookAsync compression comment and compress docs
asyncssh offered zlib first by default up to 2.17 and moved "none"
first in 2.18, so the explicit list keeps the same order on every
supported version. Neither hook reads Compression from ~/.ssh/config,
and compress is a connection extra, so the docs point at the connection
SSHRemoteJobOperator uses rather than at the triggerer.
* Raise the asyncssh floor to 2.18 and move the triggerer note to the
operator docs
With asyncssh 2.18 as the floor, SSHHookAsync no longer has to explain what
older versions offered by default. The SSH connection docs keep only what holds
for the connection; the note about zlib state on a triggerer moves to the
SSHRemoteJobOperator page.
* Test that asyncssh offers no compression first by default
SSHHookAsync passes its own compression list because asyncssh prefers none
from 2.18, the floor. The test fails on an older asyncssh, where zlib came
first, so lowering the floor means handling both defaults in the hook.
---
providers/ssh/README.rst | 2 +-
providers/ssh/docs/connections/ssh.rst | 1 +
providers/ssh/docs/index.rst | 2 +-
providers/ssh/docs/operators/ssh_remote_job.rst | 5 +
providers/ssh/pyproject.toml | 4 +-
.../ssh/src/airflow/providers/ssh/hooks/ssh.py | 22 +++-
.../ssh/tests/unit/ssh/hooks/test_ssh_async.py | 117 +++++++++++++--------
uv.lock | 2 +-
8 files changed, 104 insertions(+), 51 deletions(-)
diff --git a/providers/ssh/README.rst b/providers/ssh/README.rst
index 40753ae63cb..bd337b3e7e8 100644
--- a/providers/ssh/README.rst
+++ b/providers/ssh/README.rst
@@ -55,7 +55,7 @@ PIP package Version required
========================================== ==================
``apache-airflow`` ``>=2.11.0``
``apache-airflow-providers-common-compat`` ``>=1.12.0``
-``asyncssh`` ``>=2.12.0``
+``asyncssh`` ``>=2.18.0``
``paramiko`` ``>=4.0.0``
========================================== ==================
diff --git a/providers/ssh/docs/connections/ssh.rst
b/providers/ssh/docs/connections/ssh.rst
index 9cfa3f88cdd..9477b9400da 100644
--- a/providers/ssh/docs/connections/ssh.rst
+++ b/providers/ssh/docs/connections/ssh.rst
@@ -51,6 +51,7 @@ Extra (optional)
* ``timeout`` - Deprecated - use conn_timeout instead.
* ``cmd_timeout`` - Timeout (in seconds) for executing the command. The
default is 10 seconds. `null` value means no timeout.
* ``compress`` - ``true`` to ask the remote client/server to compress
traffic; ``false`` to refuse compression. Default is ``true``.
+ Neither hook reads ``Compression`` from ``~/.ssh/config``. Each
compressed connection holds about 0.3 MB of zlib state while open.
* ``no_host_key_check`` - Set to ``true`` to connect to hosts that have no
entry in ``~/.ssh/known_hosts`` (Hosts file), accepting unknown host keys
without requiring a known-hosts entry. Default is ``false``, which restricts
connecting to hosts already present in the known hosts file. The default
provides maximum protection against trojan horse attacks, but can be
troublesome when the ``/etc/ssh/ssh_known_hosts`` file is poorly maintained or
connections to new hosts are frequently m [...]
* ``allow_host_key_change`` - Set to ``true`` if you want to allow
connecting to hosts that has host key changed or when you get 'REMOTE HOST
IDENTIFICATION HAS CHANGED' error. This won't protect against
Man-In-The-Middle attacks. Other possible solution is to remove the host entry
from ``~/.ssh/known_hosts`` file. Default is ``false``.
* ``look_for_keys`` - Set to ``false`` if you want to disable searching
for discoverable private key files in ``~/.ssh/``
diff --git a/providers/ssh/docs/index.rst b/providers/ssh/docs/index.rst
index 44894c6de33..6050e6b4659 100644
--- a/providers/ssh/docs/index.rst
+++ b/providers/ssh/docs/index.rst
@@ -94,7 +94,7 @@ PIP package Version required
========================================== ==================
``apache-airflow`` ``>=2.11.0``
``apache-airflow-providers-common-compat`` ``>=1.12.0``
-``asyncssh`` ``>=2.12.0``
+``asyncssh`` ``>=2.18.0``
``paramiko`` ``>=4.0.0``
========================================== ==================
diff --git a/providers/ssh/docs/operators/ssh_remote_job.rst
b/providers/ssh/docs/operators/ssh_remote_job.rst
index a21106fac2a..52c39dcfa6c 100644
--- a/providers/ssh/docs/operators/ssh_remote_job.rst
+++ b/providers/ssh/docs/operators/ssh_remote_job.rst
@@ -294,6 +294,11 @@ poll loop instead of reconnecting on every status check.
To push a high fan-out
releasing the entire fan-out simultaneously. See the "Placing Limits on
Mapped Tasks" section of
:doc:`apache-airflow:authoring-and-scheduling/dynamic-task-mapping` for the
available limits.
+The trigger's connection compresses traffic by default and holds about 0.3 MB
of zlib state for
+as long as the job runs, which adds up on a triggerer monitoring many jobs.
Setting
+``"compress": "false"`` in the extra of the SSH connection avoids it, and also
turns compression
+off for every other task using that connection.
+
Comparison with SSHOperator
----------------------------
diff --git a/providers/ssh/pyproject.toml b/providers/ssh/pyproject.toml
index 8cb9696ee74..867b9095c64 100644
--- a/providers/ssh/pyproject.toml
+++ b/providers/ssh/pyproject.toml
@@ -59,8 +59,8 @@ requires-python = ">=3.11"
# After you modify the dependencies, and rebuild your Breeze CI image with
``breeze ci-image build``
dependencies = [
"apache-airflow>=2.11.0",
- "apache-airflow-providers-common-compat>=1.12.0",
- "asyncssh>=2.12.0",
+ "apache-airflow-providers-common-compat>=1.12.0", # use next version
+ "asyncssh>=2.18.0",
"paramiko>=4.0.0",
]
diff --git a/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py
b/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py
index cbfad4a1ecc..9481c943a25 100644
--- a/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py
+++ b/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py
@@ -33,7 +33,7 @@ from paramiko.config import SSH_PORT
from tenacity import Retrying, stop_after_attempt, wait_fixed, wait_random
from airflow.exceptions import AirflowProviderDeprecationWarning
-from airflow.providers.common.compat.connection import get_async_connection
+from airflow.providers.common.compat.connection import get_async_connection,
get_async_extra_dejson
from airflow.providers.common.compat.sdk import AirflowException, BaseHook
from airflow.providers.ssh.tunnel import AsyncSSHTunnel, SSHTunnel
from airflow.utils.platform import getuser
@@ -642,10 +642,16 @@ class SSHHookAsync(BaseHook):
self.passphrase = passphrase
self.private_key = private_key
self.keepalive_interval = keepalive_interval
+ # Default value, overridable from Connection, as on SSHHook
+ self.compress = True
+
+ def _parse_extras(self, conn: Any, extra_options: dict[str, Any]) -> None:
+ """
+ Parse the connection's extra fields into instance fields.
- def _parse_extras(self, conn: Any) -> None:
- """Parse extra fields from the connection into instance fields."""
- extra_options = conn.extra_dejson
+ :param conn: the connection, for its host
+ :param extra_options: the connection's extra, already read without a
synchronous SDK call
+ """
if "key_file" in extra_options and self.key_file == "":
self.key_file = extra_options["key_file"]
if "known_hosts" in extra_options:
@@ -658,6 +664,8 @@ class SSHHookAsync(BaseHook):
)
if "private_key" in extra_options:
self.private_key = extra_options["private_key"]
+ if "compress" in extra_options and
str(extra_options["compress"]).lower() == "false":
+ self.compress = False
host_key = extra_options.get("host_key")
nhkc_raw = extra_options.get("no_host_key_check")
@@ -708,7 +716,9 @@ class SSHHookAsync(BaseHook):
conn = await get_async_connection(self.ssh_conn_id)
if conn.extra is not None:
- self._parse_extras(conn)
+ # extra_dejson masks the secrets with a synchronous SDK call,
which raises
+ # DeadlockImminentError in an async task; read the extra
asynchronously.
+ self._parse_extras(conn, await get_async_extra_dejson(conn))
def _get_value(self_val, conn_val, default=None):
if self_val is not None:
@@ -722,6 +732,8 @@ class SSHHookAsync(BaseHook):
"port": _get_value(self.port, conn.port, SSH_PORT),
"username": _get_value(self.username, conn.login),
"password": _get_value(self.password, conn.password),
+ # asyncssh offers "none" first by default; offer zlib first, as
paramiko does for SSHHook.
+ "compression_algs": ["[email protected]", "zlib", "none"] if
self.compress else ["none"],
}
if self.key_file:
conn_config["client_keys"] = self.key_file
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 5598f4ee288..df7f05e8977 100644
--- a/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py
+++ b/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py
@@ -21,7 +21,9 @@ import json
import warnings
from unittest import mock
+import asyncssh
import pytest
+from asyncssh.compression import get_default_compression_algs
from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.models import Connection
@@ -32,6 +34,11 @@ def _connection(extra: dict, host: str = "test.host") ->
Connection:
return Connection(conn_id="test_conn", conn_type="ssh", host=host,
extra=json.dumps(extra))
+def _parse_extra_dict(hook: SSHHookAsync, extra: dict, host: str =
"test.host") -> None:
+ """Parse *extra* as ``_get_conn`` does: the connection plus its extra,
read beforehand."""
+ hook._parse_extras(_connection(extra, host=host), extra)
+
+
class TestSSHHookAsync:
def test_init_with_conn_id(self):
"""Test initialization with connection ID."""
@@ -62,28 +69,22 @@ class TestSSHHookAsync:
def test_parse_extras_key_file(self):
"""Test parsing key_file from connection extras."""
hook = SSHHookAsync(ssh_conn_id="test_conn")
- mock_conn = mock.MagicMock()
- mock_conn.extra_dejson = {"key_file": "/path/to/key"}
- mock_conn.host = "test.host"
- hook._parse_extras(mock_conn)
+ _parse_extra_dict(hook, {"key_file": "/path/to/key"})
assert hook.key_file == "/path/to/key"
def test_parse_extras_no_host_key_check(self):
"""Test parsing no_host_key_check from connection extras."""
hook = SSHHookAsync(ssh_conn_id="test_conn")
- mock_conn = mock.MagicMock()
- mock_conn.extra_dejson = {"no_host_key_check": "true"}
- mock_conn.host = "test.host"
- hook._parse_extras(mock_conn)
+ _parse_extra_dict(hook, {"no_host_key_check": "true"})
assert hook.known_hosts == "none"
def test_parse_extras_no_host_key_check_defaults_to_false(self):
"""A connection that omits ``no_host_key_check`` keeps host key
verification enabled."""
hook = SSHHookAsync(ssh_conn_id="test_conn")
- hook._parse_extras(_connection({}))
+ _parse_extra_dict(hook, {})
assert hook.known_hosts != "none"
def test_parse_extras_honours_deprecated_alias(self):
@@ -91,7 +92,7 @@ class TestSSHHookAsync:
hook = SSHHookAsync(ssh_conn_id="test_conn")
with pytest.warns(AirflowProviderDeprecationWarning,
match="ignore_hostkey_verification"):
- hook._parse_extras(_connection({"ignore_hostkey_verification":
True}))
+ _parse_extra_dict(hook, {"ignore_hostkey_verification": True})
assert hook.known_hosts == "none"
def test_parse_extras_canonical_key_wins_over_alias(self):
@@ -100,65 +101,50 @@ class TestSSHHookAsync:
with warnings.catch_warnings():
warnings.simplefilter("error", AirflowProviderDeprecationWarning)
- hook._parse_extras(_connection({"no_host_key_check": False,
"ignore_hostkey_verification": True}))
+ _parse_extra_dict(hook, {"no_host_key_check": False,
"ignore_hostkey_verification": True})
assert hook.known_hosts != "none"
def test_parse_extras_host_key_uses_host_override(self):
"""The known_hosts entry names the host actually connected to, not the
connection's host."""
hook = SSHHookAsync(ssh_conn_id="test_conn", host="override.example")
- hook._parse_extras(_connection({"host_key": "ssh-ed25519 AAAAC3..."},
host="connection.example"))
+ _parse_extra_dict(hook, {"host_key": "ssh-ed25519 AAAAC3..."},
host="connection.example")
assert hook.known_hosts == b"override.example ssh-ed25519 AAAAC3..."
def test_parse_extras_bare_host_key_is_rsa(self):
"""A bare base64 host key is RSA, as on the sync hook, and asyncssh
needs the type."""
hook = SSHHookAsync(ssh_conn_id="test_conn")
- hook._parse_extras(_connection({"host_key": "AAAAB3NzaC1yc2E"}))
+ _parse_extra_dict(hook, {"host_key": "AAAAB3NzaC1yc2E"})
assert hook.known_hosts == b"test.host ssh-rsa AAAAB3NzaC1yc2E"
def test_parse_extras_host_key(self):
"""Test parsing host_key from connection extras."""
hook = SSHHookAsync(ssh_conn_id="test_conn")
- mock_conn = mock.MagicMock()
- mock_conn.extra_dejson = {"host_key": " ssh-rsa AAAAB3... user@host ",
"no_host_key_check": "false"}
- mock_conn.host = "test.host"
- hook._parse_extras(mock_conn)
+ _parse_extra_dict(hook, {"host_key": " ssh-rsa AAAAB3... user@host ",
"no_host_key_check": "false"})
assert hook.known_hosts == b"test.host ssh-rsa AAAAB3..."
def test_parse_extras_dss_host_key_raises(self):
"""Test that ssh-dss host_key is rejected."""
hook = SSHHookAsync(ssh_conn_id="test_conn")
- mock_conn = mock.MagicMock()
- mock_conn.extra_dejson = {"host_key": "ssh-dss\tAAAAB3...",
"no_host_key_check": "false"}
- mock_conn.host = "test.host"
with pytest.raises(ValueError, match="DSA/DSS host keys"):
- hook._parse_extras(mock_conn)
+ _parse_extra_dict(hook, {"host_key": "ssh-dss\tAAAAB3...",
"no_host_key_check": "false"})
def test_parse_extras_host_key_with_no_check_raises(self):
"""Test that host_key with no_host_key_check raises error."""
hook = SSHHookAsync(ssh_conn_id="test_conn")
- mock_conn = mock.MagicMock()
- mock_conn.extra_dejson = {
- "host_key": "ssh-rsa AAAAB3...",
- "no_host_key_check": "true",
- }
- mock_conn.host = "test.host"
with pytest.raises(ValueError, match="Host key check was skipped"):
- hook._parse_extras(mock_conn)
+ _parse_extra_dict(hook, {"host_key": "ssh-rsa AAAAB3...",
"no_host_key_check": "true"})
def test_parse_extras_private_key(self):
"""Test parsing private_key from connection extras."""
hook = SSHHookAsync(ssh_conn_id="test_conn")
- mock_conn = mock.MagicMock()
test_key = "test-private-key-content"
- mock_conn.extra_dejson = {"private_key": test_key}
- mock_conn.host = "test.host"
- hook._parse_extras(mock_conn)
+ _parse_extra_dict(hook, {"private_key": test_key})
assert hook.private_key == test_key
@pytest.mark.asyncio
@@ -171,19 +157,15 @@ class TestSSHHookAsync:
username="testuser",
)
- mock_conn_obj = mock.MagicMock()
- mock_conn_obj.extra_dejson = {"no_host_key_check": "true"}
- mock_conn_obj.host = None
- mock_conn_obj.port = None
- mock_conn_obj.login = None
- mock_conn_obj.password = None
- mock_conn_obj.extra = "{}"
+ mock_conn_obj = Connection(
+ conn_id="test_conn", conn_type="ssh",
extra=json.dumps({"no_host_key_check": "true"})
+ )
- mock_ssh_client = mock.AsyncMock()
+ mock_ssh_client = mock.AsyncMock(spec=asyncssh.SSHClientConnection)
with mock.patch(
"airflow.providers.ssh.hooks.ssh.get_async_connection",
- new_callable=mock.AsyncMock,
+ autospec=True,
return_value=mock_conn_obj,
):
with mock.patch("asyncssh.connect", new_callable=mock.AsyncMock)
as mock_connect:
@@ -197,6 +179,59 @@ class TestSSHHookAsync:
assert call_kwargs["username"] == "testuser"
assert result == mock_ssh_client
+ @mock.patch.object(Connection, "extra_dejson",
new_callable=mock.PropertyMock, side_effect=AssertionError)
+ @mock.patch(
+ "airflow.providers.ssh.hooks.ssh.get_async_extra_dejson",
+ autospec=True,
+ return_value={"no_host_key_check": "true"},
+ )
+ # asyncssh.connect is wrapped by @async_context_manager, so autospec would
not be awaitable.
+ @mock.patch("asyncssh.connect", new_callable=mock.AsyncMock)
+ @mock.patch("airflow.providers.ssh.hooks.ssh.get_async_connection",
autospec=True)
+ @pytest.mark.asyncio
+ async def test_get_conn_reads_extra_asynchronously(
+ self, mock_get_connection, mock_connect, mock_get_extra,
mock_extra_dejson
+ ):
+ """``extra_dejson`` masks synchronously, so the async hook reads the
extra through common.compat."""
+ # The connection's own extra is empty: only the asynchronously read
extra can disable the check.
+ mock_get_connection.return_value = _connection({})
+
+ await SSHHookAsync(ssh_conn_id="test_conn")._get_conn()
+
+
mock_get_extra.assert_awaited_once_with(mock_get_connection.return_value)
+ mock_extra_dejson.assert_not_called()
+ assert mock_connect.call_args.kwargs["known_hosts"] is None
+
+ @pytest.mark.parametrize(
+ ("extra", "compression_algs"),
+ [
+ pytest.param({}, ["[email protected]", "zlib", "none"],
id="default"),
+ pytest.param({"compress": "true"}, ["[email protected]", "zlib",
"none"], id="true"),
+ pytest.param({"compress": "false"}, ["none"], id="false-string"),
+ pytest.param({"compress": False}, ["none"], id="false-bool"),
+ ],
+ )
+ @mock.patch("asyncssh.connect", new_callable=mock.AsyncMock)
+ @mock.patch("airflow.providers.ssh.hooks.ssh.get_async_connection",
autospec=True)
+ @pytest.mark.asyncio
+ async def test_get_conn_compresses_like_sync_hook(
+ self, mock_get_connection, mock_connect, extra, compression_algs
+ ):
+ """zlib is offered unless the ``compress`` extra is false, as
``SSHHook`` does."""
+ mock_get_connection.return_value = _connection({"no_host_key_check":
"true", **extra})
+
+ await SSHHookAsync(ssh_conn_id="test_conn")._get_conn()
+
+ assert mock_connect.call_args.kwargs["compression_algs"] ==
compression_algs
+
+ def test_asyncssh_offers_no_compression_first_by_default(self):
+ """
+ ``_get_conn`` passes its own list because asyncssh prefers ``none``
from 2.18, the floor.
+
+ Older versions preferred zlib, so supporting them means handling both
defaults in the hook.
+ """
+ assert get_default_compression_algs()[0] == b"none"
+
@mock.patch("airflow.providers.ssh.hooks.ssh.os.path.isfile",
return_value=False)
@mock.patch("asyncssh.connect", new_callable=mock.AsyncMock)
@mock.patch(
diff --git a/uv.lock b/uv.lock
index c6d4f6b2c83..a1af60266fe 100644
--- a/uv.lock
+++ b/uv.lock
@@ -8654,7 +8654,7 @@ docs = [
requires-dist = [
{ name = "apache-airflow", editable = "." },
{ name = "apache-airflow-providers-common-compat", editable =
"providers/common/compat" },
- { name = "asyncssh", specifier = ">=2.12.0" },
+ { name = "asyncssh", specifier = ">=2.18.0" },
{ name = "paramiko", specifier = ">=4.0.0" },
]