This is an automated email from the ASF dual-hosted git repository.
kaxil 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 5472d642898 Fix Snowflake connections ignoring region with
`snowflake-connector-python` 5 (#75334)
5472d642898 is described below
commit 5472d642898faa9da3eada2ce2e98a76d6cfdd10
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Oct 21 21:16:42 2026 +0100
Fix Snowflake connections ignoring region with `snowflake-connector-python`
5 (#75334)
snowflake-connector-python 5.0 no longer accepts `region`: it warns
"Unknown parameter 'region'" and builds the host from `account` alone, so a
connection with the `region` extra dials the wrong host. On connector 5 the
hook
now passes the host earlier connectors built from the region instead.
Connector 5 also warns on every connection unless
`enable_server_session_keep_alive_auto_detection` is set, and plans to
change
its default. The hook now sets it to True, the behaviour of earlier
connectors, and exposes it as a connection extra.
* Tolerate a missing region in Snowflake connector-5 params
* Fix mypy return type in Snowflake connect-kwargs test helper
---
providers/snowflake/docs/connections/snowflake.rst | 6 +
providers/snowflake/provider.yaml | 8 +
.../providers/snowflake/get_provider_info.py | 5 +
.../airflow/providers/snowflake/hooks/snowflake.py | 67 +++++++-
.../tests/unit/snowflake/hooks/test_snowflake.py | 175 ++++++++++++++++++++-
5 files changed, 257 insertions(+), 4 deletions(-)
diff --git a/providers/snowflake/docs/connections/snowflake.rst
b/providers/snowflake/docs/connections/snowflake.rst
index 90002bfde79..51b2b749f3a 100644
--- a/providers/snowflake/docs/connections/snowflake.rst
+++ b/providers/snowflake/docs/connections/snowflake.rst
@@ -100,6 +100,12 @@ Extra (optional)
* ``host``: Target Snowflake hostname to connect to (e.g., for local
testing with LocalStack).
* ``port``: Target Snowflake port to connect to (e.g., for local testing
with LocalStack).
* ``ocsp_fail_open``: Specify `ocsp_fail_open
<https://docs.snowflake.com/en/developer-guide/python-connector/python-connector-connect#label-python-ocsp-choosing-fail-open-or-fail-close-mode>`_.
+ * ``enable_server_session_keep_alive_auto_detection``: Whether closing the
connection checks for
+ async queries still running on it first. ``true`` (the default) keeps
the server session open
+ while any are running, so they finish; ``false`` always logs out on
close, and Snowflake may
+ cancel those queries. Takes effect with
``snowflake-connector-python>=5.0.0``; earlier connectors
+ always keep the session while async queries run. The hook sets ``true``
when the extra is
+ absent, so the connector's planned default change does not alter
behaviour.
* ``proxy_host``: Proxy hostname to use for connecting to Snowflake.
* ``proxy_port``: Proxy port to use for connecting to Snowflake.
* ``proxy_user``: Proxy username for authentication with the proxy server.
diff --git a/providers/snowflake/provider.yaml
b/providers/snowflake/provider.yaml
index 71cd310e438..4f6d31a0123 100644
--- a/providers/snowflake/provider.yaml
+++ b/providers/snowflake/provider.yaml
@@ -277,6 +277,14 @@ connection-types:
- boolean
- 'null'
description: Turns off OCSP certificate checks
+ enable_server_session_keep_alive_auto_detection:
+ label: Keep session while async queries run
+ schema:
+ type:
+ - boolean
+ - 'null'
+ default: true
+ description: Skip logout on close while async queries are running
(snowflake-connector-python 5.0+)
proxy_host:
label: Proxy Host
schema:
diff --git
a/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
b/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
index e0ed30efd8e..bc8edf6ef53 100644
--- a/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
+++ b/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
@@ -155,6 +155,11 @@ def get_provider_info():
"schema": {"type": ["boolean", "null"]},
"description": "Turns off OCSP certificate checks",
},
+ "enable_server_session_keep_alive_auto_detection": {
+ "label": "Keep session while async queries run",
+ "schema": {"type": ["boolean", "null"], "default":
True},
+ "description": "Skip logout on close while async
queries are running (snowflake-connector-python 5.0+)",
+ },
"proxy_host": {"label": "Proxy Host", "schema": {"type":
["string", "null"]}},
"proxy_port": {"label": "Proxy Port", "schema": {"type":
["integer", "null"]}},
"proxy_user": {"label": "Proxy User", "schema": {"type":
["string", "null"]}},
diff --git
a/providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake.py
b/providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake.py
index 9aac0e6dfe1..52d2f03d541 100644
--- a/providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake.py
+++ b/providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake.py
@@ -53,6 +53,10 @@ from airflow.utils.strings import to_boolean
OAUTH_REQUEST_TIMEOUT = 30 # seconds, avoid hanging tasks on token request
OAUTH_EXPIRY_BUFFER = 30
SUPPORTED_GRANT_TYPES = {"refresh_token", "client_credentials", "password"}
+LOGOUT_AUTO_DETECTION_PARAM = "enable_server_session_keep_alive_auto_detection"
+# Logout behaviour of connectors before 5.0: keep the server session while
async queries
+# started on the connection are still running. Connector 5 plans to change its
default.
+LOGOUT_AUTO_DETECTION_DEFAULT = True
T = TypeVar("T")
@@ -71,6 +75,20 @@ def _try_to_boolean(value: Any):
return value
+def _is_connector_5_or_later() -> bool:
+ """
+ Return True when the installed ``snowflake-connector-python`` is 5.0 or
later.
+
+ Connector 5 no longer accepts ``region`` and is the first to accept
+ ``enable_server_session_keep_alive_auto_detection``. Remove this and the
pre-5 branches
+ once the provider requires ``snowflake-connector-python>=5.0``.
+ """
+ # Lazy: importing snowflake.connector loads the whole connector.
+ from snowflake.connector.version import VERSION
+
+ return VERSION >= (5,)
+
+
def _is_retryable_oauth_error(exception: BaseException) -> bool:
"""Return True if exception is retryable for OAuth token request."""
if isinstance(exception, (ConnectionError, Timeout)):
@@ -295,6 +313,12 @@ class SnowflakeHook(DbApiHook):
"insecure_mode": BooleanField(
label=lazy_gettext("Insecure mode"), description="Turns off
OCSP certificate checks"
),
+ LOGOUT_AUTO_DETECTION_PARAM: BooleanField(
+ label=lazy_gettext("Keep session while async queries run"),
+ description="Skip logout on close while async queries are
running "
+ "(snowflake-connector-python 5.0+)",
+ default=True,
+ ),
"proxy_host": StringField(lazy_gettext("Proxy Host"),
widget=BS3TextFieldWidget()),
"proxy_port": IntegerField(
lazy_gettext("Proxy Port"),
@@ -552,6 +576,11 @@ class SnowflakeHook(DbApiHook):
if ocsp_fail_open is not None:
conn_config["ocsp_fail_open"] = _try_to_boolean(ocsp_fail_open)
+ logout_auto_detection = extra_dict.get(LOGOUT_AUTO_DETECTION_PARAM)
+
+ if logout_auto_detection is not None:
+ conn_config[LOGOUT_AUTO_DETECTION_PARAM] =
_try_to_boolean(logout_auto_detection)
+
def _add_workload_identity_config(
self,
conn_config: dict[str, Any],
@@ -815,6 +844,7 @@ class SnowflakeHook(DbApiHook):
"client_store_temporary_credential",
"json_result_force_utf8_decoding",
"ocsp_fail_open",
+ LOGOUT_AUTO_DETECTION_PARAM,
"proxy_host",
"proxy_port",
"proxy_user",
@@ -823,11 +853,38 @@ class SnowflakeHook(DbApiHook):
}
)
+ def _get_connector_params(self) -> dict[str, Any]:
+ """
+ Return ``_get_conn_params()`` in the form the installed
``snowflake-connector-python`` accepts.
+
+ Connector 5 ignores ``region`` and builds the host from ``account``
alone, so the hook
+ passes the host earlier connectors built from the region instead. It
also warns on every
+ connection unless ``enable_server_session_keep_alive_auto_detection``
is set, which earlier
+ connectors don't accept.
+ """
+ conn_params: dict[str, Any] = self._get_conn_params()
+
+ if not _is_connector_5_or_later():
+ conn_params.pop(LOGOUT_AUTO_DETECTION_PARAM, None)
+ return conn_params
+
+ region = conn_params.pop("region", None)
+ # Same host as construct_hostname() in earlier connectors: "us-west-2"
is the default
+ # deployment and adds nothing, and the region replaces any region in
the account.
+ if region and region != "us-west-2" and "host" not in conn_params:
+ account =
_validate_account_component(conn_params["account"].split(".")[0], "account")
+ region = _validate_account_component(region, "region")
+ top_level_domain = "cn" if region.lower().startswith("cn-") else
"com"
+ conn_params["account"] = account
+ conn_params["host"] =
f"{account}.{region}.snowflakecomputing.{top_level_domain}"
+ conn_params.setdefault(LOGOUT_AUTO_DETECTION_PARAM,
LOGOUT_AUTO_DETECTION_DEFAULT)
+ return conn_params
+
def get_conn(self) -> SnowflakeConnection:
"""Return a snowflake.connection object."""
from snowflake.connector import connect
- conn_config = self._get_conn_params()
+ conn_config = self._get_connector_params()
conn = connect(**conn_config)
return conn
@@ -849,6 +906,12 @@ class SnowflakeHook(DbApiHook):
if "ocsp_fail_open" in conn_params:
engine_kwargs.setdefault("connect_args", {})
engine_kwargs["connect_args"]["ocsp_fail_open"] =
conn_params["ocsp_fail_open"]
+ if _is_connector_5_or_later():
+ engine_kwargs.setdefault("connect_args", {})
+ engine_kwargs["connect_args"].setdefault(
+ LOGOUT_AUTO_DETECTION_PARAM,
+ conn_params.get(LOGOUT_AUTO_DETECTION_PARAM,
LOGOUT_AUTO_DETECTION_DEFAULT),
+ )
for key in ["session_parameters", "private_key"]:
if conn_params.get(key):
engine_kwargs.setdefault("connect_args", {})
@@ -866,7 +929,7 @@ class SnowflakeHook(DbApiHook):
from airflow import __version__ as airflow_version
from airflow.providers.snowflake import __version__ as provider_version
- conn_config = self._get_conn_params()
+ conn_config = self._get_connector_params()
session = Session.builder.configs(conn_config).create()
# add query tag for observability
session.update_query_tag(
diff --git a/providers/snowflake/tests/unit/snowflake/hooks/test_snowflake.py
b/providers/snowflake/tests/unit/snowflake/hooks/test_snowflake.py
index 13a70cd2cb9..897a7f8a224 100644
--- a/providers/snowflake/tests/unit/snowflake/hooks/test_snowflake.py
+++ b/providers/snowflake/tests/unit/snowflake/hooks/test_snowflake.py
@@ -35,7 +35,7 @@ from requests.exceptions import ConnectionError, HTTPError
from airflow.models import Connection
from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException, timezone
-from airflow.providers.snowflake.hooks.snowflake import SnowflakeHook
+from airflow.providers.snowflake.hooks.snowflake import SnowflakeHook,
_is_connector_5_or_later
if TYPE_CHECKING:
from pathlib import Path
@@ -73,6 +73,40 @@ CONN_PARAMS_OAUTH_BASE = {
CONN_PARAMS_OAUTH = CONN_PARAMS_OAUTH_BASE | {"refresh_token": "secrettoken"}
[email protected]
+def connector_5():
+ with mock.patch(
+ "airflow.providers.snowflake.hooks.snowflake._is_connector_5_or_later",
+ autospec=True,
+ return_value=True,
+ ):
+ yield
+
+
[email protected]
+def connector_before_5():
+ with mock.patch(
+ "airflow.providers.snowflake.hooks.snowflake._is_connector_5_or_later",
+ autospec=True,
+ return_value=False,
+ ):
+ yield
+
+
+def _get_conn_connect_kwargs(extra: dict[str, Any]) -> dict[str, Any]:
+ """Return the kwargs ``SnowflakeHook.get_conn`` passes to
``snowflake.connector.connect``."""
+ connection_kwargs = deepcopy(BASE_CONNECTION_KWARGS)
+ connection_kwargs["extra"].update(extra)
+
+ with (
+ mock.patch.dict("os.environ",
AIRFLOW_CONN_TEST_CONN=Connection(**connection_kwargs).get_uri()),
+ mock.patch("snowflake.connector.connect") as mock_connect,
+ ):
+ SnowflakeHook(snowflake_conn_id="test_conn").get_conn()
+
+ return dict(mock_connect.call_args.kwargs)
+
+
@pytest.fixture
def unencrypted_temporary_private_key(tmp_path: Path) -> Path:
key = rsa.generate_private_key(backend=default_backend(),
public_exponent=65537, key_size=2048)
@@ -107,6 +141,20 @@ def
base64_encoded_encrypted_private_key(encrypted_temporary_private_key: Path)
return
base64.b64encode(encrypted_temporary_private_key.read_bytes()).decode("utf-8")
[email protected](
+ ("version", "expected"),
+ [
+ pytest.param((3, 17, 0, None), False, id="3.17"),
+ pytest.param((4, 8, 0, None), False, id="4.8"),
+ pytest.param((5, 0, 0, None), True, id="5.0"),
+ pytest.param((5, 1, 0, None), True, id="5.1"),
+ ],
+)
+def test_is_connector_5_or_later(version, expected):
+ with mock.patch("snowflake.connector.version.VERSION", version):
+ assert _is_connector_5_or_later() is expected
+
+
class TestPytestSnowflakeHook:
@pytest.mark.parametrize(
("connection_kwargs", "expected_uri", "expected_conn_params"),
@@ -837,6 +885,7 @@ class TestPytestSnowflakeHook:
== "PARTNER_NAME"
)
+ @pytest.mark.usefixtures("connector_before_5")
def test_get_conn_should_call_connect(self):
with (
mock.patch.dict(
@@ -849,6 +898,108 @@ class TestPytestSnowflakeHook:
mock_connect.assert_called_once_with(**hook._get_conn_params())
assert mock_connect.return_value == conn
+ @pytest.mark.parametrize(
+ ("extra", "expected_account", "expected_host"),
+ [
+ pytest.param({"region": ""}, "airflow", None, id="no-region"),
+ pytest.param(
+ {"region": "us-east-2.aws"},
+ "airflow",
+ "airflow.us-east-2.aws.snowflakecomputing.com",
+ id="region",
+ ),
+ pytest.param(
+ {"region": "cn-north-1.aws"},
+ "airflow",
+ "airflow.cn-north-1.aws.snowflakecomputing.cn",
+ id="china-region",
+ ),
+ pytest.param(
+ {"account": "airflow.eu-west-1", "region": "us-east-2.aws"},
+ "airflow",
+ "airflow.us-east-2.aws.snowflakecomputing.com",
+ id="region-replaces-account-region",
+ ),
+ pytest.param({"region": "us-west-2"}, "airflow", None,
id="default-region"),
+ pytest.param(
+ {"region": "us-east-2.aws", "host": "localhost"},
+ "airflow",
+ "localhost",
+ id="explicit-host-ignores-region",
+ ),
+ ],
+ )
+ @pytest.mark.usefixtures("connector_5")
+ def test_get_conn_on_connector_5_builds_host_from_region(self, extra,
expected_account, expected_host):
+ connect_kwargs = _get_conn_connect_kwargs(extra)
+
+ assert "region" not in connect_kwargs
+ assert connect_kwargs["account"] == expected_account
+ assert connect_kwargs.get("host") == expected_host
+
+ @pytest.mark.usefixtures("connector_5")
+ def test_get_conn_on_connector_5_rejects_region_that_changes_host(self):
+ with pytest.raises(ValueError, match="Invalid Snowflake region"):
+ _get_conn_connect_kwargs({"region": "evil.example/x"})
+
+ @pytest.mark.parametrize(
+ ("extra", "expected"),
+ [
+ pytest.param({}, True, id="unset-keeps-earlier-behaviour"),
+ pytest.param({"enable_server_session_keep_alive_auto_detection":
False}, False, id="false"),
+ pytest.param({"enable_server_session_keep_alive_auto_detection":
"false"}, False, id="false-str"),
+ pytest.param({"enable_server_session_keep_alive_auto_detection":
True}, True, id="true"),
+ ],
+ )
+ @pytest.mark.usefixtures("connector_5")
+ def test_get_conn_on_connector_5_sets_logout_auto_detection(self, extra,
expected):
+ connect_kwargs = _get_conn_connect_kwargs(extra)
+
+ assert
connect_kwargs["enable_server_session_keep_alive_auto_detection"] is expected
+
+ @pytest.mark.usefixtures("connector_before_5")
+ def test_get_conn_before_connector_5_drops_logout_auto_detection(self):
+ connect_kwargs =
_get_conn_connect_kwargs({"enable_server_session_keep_alive_auto_detection":
False})
+
+ assert "enable_server_session_keep_alive_auto_detection" not in
connect_kwargs
+
+ @pytest.mark.parametrize(
+ ("extra", "engine_kwargs", "expected"),
+ [
+ pytest.param({}, None, True, id="unset"),
+ pytest.param(
+ {"enable_server_session_keep_alive_auto_detection": False},
None, False, id="connection-false"
+ ),
+ pytest.param(
+ {},
+ {"connect_args":
{"enable_server_session_keep_alive_auto_detection": False}},
+ False,
+ id="caller-false",
+ ),
+ ],
+ )
+ @pytest.mark.usefixtures("connector_5")
+ def test_get_sqlalchemy_engine_on_connector_5_passes_logout_auto_detection(
+ self, extra, engine_kwargs, expected
+ ):
+ connection_kwargs = deepcopy(BASE_CONNECTION_KWARGS)
+ connection_kwargs["extra"].update(extra)
+
+ with (
+ mock.patch.dict("os.environ",
AIRFLOW_CONN_TEST_CONN=Connection(**connection_kwargs).get_uri()),
+ mock.patch(
+ "airflow.providers.snowflake.hooks.snowflake.create_engine",
autospec=True
+ ) as mock_create_engine,
+ ):
+
SnowflakeHook(snowflake_conn_id="test_conn").get_sqlalchemy_engine(engine_kwargs=engine_kwargs)
+
+ mock_create_engine.assert_called_once_with(
+ "snowflake://user:[email protected]_region/db/public"
+
"?application=AIRFLOW&authenticator=snowflake&role=af_role&warehouse=af_wh",
+ connect_args={"enable_server_session_keep_alive_auto_detection":
expected},
+ )
+
+ @pytest.mark.usefixtures("connector_before_5")
def test_get_sqlalchemy_engine_should_support_pass_auth(self):
with (
mock.patch.dict(
@@ -864,6 +1015,7 @@ class TestPytestSnowflakeHook:
)
assert mock_create_engine.return_value == conn
+ @pytest.mark.usefixtures("connector_before_5")
def test_get_sqlalchemy_engine_should_support_insecure_mode(self):
connection_kwargs = deepcopy(BASE_CONNECTION_KWARGS)
connection_kwargs["extra"]["extra__snowflake__insecure_mode"] = "True"
@@ -881,6 +1033,7 @@ class TestPytestSnowflakeHook:
)
assert mock_create_engine.return_value == conn
+ @pytest.mark.usefixtures("connector_before_5")
def
test_get_sqlalchemy_engine_should_support_json_result_force_utf8_decoding(self):
connection_kwargs = deepcopy(BASE_CONNECTION_KWARGS)
connection_kwargs["extra"]["extra__snowflake__json_result_force_utf8_decoding"]
= "True"
@@ -898,6 +1051,7 @@ class TestPytestSnowflakeHook:
)
assert mock_create_engine.return_value == conn
+ @pytest.mark.usefixtures("connector_before_5")
def test_get_sqlalchemy_engine_should_support_session_parameters(self):
connection_kwargs = deepcopy(BASE_CONNECTION_KWARGS)
connection_kwargs["extra"]["session_parameters"] = {"TEST_PARAM":
"AA", "TEST_PARAM_B": 123}
@@ -929,6 +1083,7 @@ class TestPytestSnowflakeHook:
assert "private_key" in
mock_create_engine.call_args.kwargs["connect_args"]
assert mock_create_engine.return_value == conn
+ @pytest.mark.usefixtures("connector_before_5")
def test_get_sqlalchemy_engine_should_support_ocsp_fail_open(self):
connection_kwargs = deepcopy(BASE_CONNECTION_KWARGS)
connection_kwargs["extra"]["ocsp_fail_open"] = "False"
@@ -1286,6 +1441,7 @@ class TestPytestSnowflakeHook:
hook.get_openlineage_database_specific_lineage(mock.MagicMock())
@pytest.mark.skipif(sys.version_info >= (3, 12), reason="Snowpark Python
doesn't support Python 3.12 yet")
+ @pytest.mark.usefixtures("connector_5")
@mock.patch("snowflake.snowpark.Session.builder")
def test_get_snowpark_session(self, mock_session_builder):
from airflow import __version__ as airflow_version
@@ -1301,7 +1457,22 @@ class TestPytestSnowflakeHook:
session = hook.get_snowpark_session()
assert session == mock_session
-
mock_session_builder.configs.assert_called_once_with(hook._get_conn_params())
+ mock_session_builder.configs.assert_called_once_with(
+ {
+ "account": "airflow",
+ "application": "AIRFLOW",
+ "authenticator": "snowflake",
+ "database": "db",
+ "enable_server_session_keep_alive_auto_detection": True,
+ "host": "airflow.af_region.snowflakecomputing.com",
+ "password": "pw",
+ "role": "af_role",
+ "schema": "public",
+ "session_parameters": None,
+ "user": "user",
+ "warehouse": "af_wh",
+ }
+ )
# Verify that update_query_tag was called with the expected tag
dictionary
mock_session.update_query_tag.assert_called_once_with(