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

vincbeck 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 2c9088e078d Allow Variable.get to reuse the caller's database session 
(#71968)
2c9088e078d is described below

commit 2c9088e078de7acc297a998a611490632cd1fa29
Author: D. Ferruzzi <[email protected]>
AuthorDate: Mon Sep 14 09:54:11 2026 -0700

    Allow Variable.get to reuse the caller's database session (#71968)
    
    `MetastoreBackend.get_variable` is decorated with `@provide_session`. Called
    without a session it goes through `create_session()`, which for a scoped 
session
    returns *the caller's own session* and commits it on exit. So any code that 
reads
    a Variable while holding a transaction gets that transaction committed 
underneath
    it — detaching its objects, or raising `UNEXPECTED COMMIT` under the 
scheduler's
    `prohibit_commit` guard, where the error is then swallowed per-backend and
    surfaces as a missing Variable.
    
    `Variable.get` and `Variable.get_variable_from_secrets` now take an optional
    keyword-only `session`, forwarded only to `MetastoreBackend`. 
`Variable.update`
    forwards its own.
---
 airflow-core/newsfragments/71968.bugfix.rst        |   1 +
 airflow-core/src/airflow/models/variable.py        |  60 +++++++++--
 .../src/airflow/serialization/definitions/dag.py   |   2 +-
 .../airflow/serialization/definitions/deadline.py  |   8 +-
 airflow-core/tests/unit/models/test_variable.py    | 118 +++++++++++++++++++++
 .../serialization/definitions/test_deadline.py     |  12 +++
 .../src/airflow_shared/secrets_backend/base.py     |  25 +++--
 .../tests/secrets_backend/test_base.py             |  25 ++++-
 8 files changed, 225 insertions(+), 26 deletions(-)

diff --git a/airflow-core/newsfragments/71968.bugfix.rst 
b/airflow-core/newsfragments/71968.bugfix.rst
new file mode 100644
index 00000000000..a312ab29091
--- /dev/null
+++ b/airflow-core/newsfragments/71968.bugfix.rst
@@ -0,0 +1 @@
+``Variable.get``, ``Variable.get_variable_from_secrets`` and 
``Variable.setdefault`` now accept an optional keyword-only ``session`` that is 
forwarded to the metastore secrets backend.  A lookup made while holding an 
open session (most notably inside the scheduler's ``prohibit_commit`` guard) 
now reuses the caller's transaction instead of opening a scoped session and 
committing it.  ``Variable.update`` now forwards its session to its own 
existence check, which had the same problem.
diff --git a/airflow-core/src/airflow/models/variable.py 
b/airflow-core/src/airflow/models/variable.py
index bcbc07a9703..1c53237aa16 100644
--- a/airflow-core/src/airflow/models/variable.py
+++ b/airflow-core/src/airflow/models/variable.py
@@ -28,7 +28,7 @@ from sqlalchemy import Boolean, ForeignKey, Integer, String, 
Text, delete, or_,
 from sqlalchemy.dialects.mysql import MEDIUMTEXT
 from sqlalchemy.orm import Mapped, declared_attr, mapped_column, 
reconstructor, synonym
 
-from airflow._shared.secrets_backend.base import call_secrets_backend_method
+from airflow._shared.secrets_backend.base import accepts_kwarg, 
call_secrets_backend_method
 from airflow._shared.secrets_masker import mask_secret
 from airflow.configuration import conf, ensure_secrets_loaded
 from airflow.models.base import ID_LEN, Base
@@ -126,7 +126,15 @@ class Variable(Base, LoggingMixin):
         return synonym("_val", descriptor=property(cls.get_val, cls.set_val))
 
     @classmethod
-    def setdefault(cls, key, default, description=None, 
deserialize_json=False):
+    def setdefault(
+        cls,
+        key: str,
+        default: Any,
+        description: str | None = None,
+        deserialize_json: bool = False,
+        *,
+        session: Session | None = None,
+    ) -> Any:
         """
         Return the current value for a key or store the default value and 
return it.
 
@@ -138,13 +146,19 @@ class Variable(Base, LoggingMixin):
         :param description: Default value to set Description of the Variable
         :param deserialize_json: Store this as a JSON encoded value in the DB
             and un-encode it when retrieving a value
-        :param session: Session
+        :param session: Existing SQLAlchemy Session. Callers holding an open 
transaction must pass it.
         :return: Mixed
         """
-        obj = Variable.get(key, default_var=None, 
deserialize_json=deserialize_json)
+        obj = Variable.get(key, default_var=None, 
deserialize_json=deserialize_json, session=session)
         if obj is None:
             if default is not None:
-                Variable.set(key=key, value=default, description=description, 
serialize_json=deserialize_json)
+                Variable.set(
+                    key=key,
+                    value=default,
+                    description=description,
+                    serialize_json=deserialize_json,
+                    session=session,
+                )
                 return default
             raise ValueError("Default Value must be set")
         return obj
@@ -156,6 +170,8 @@ class Variable(Base, LoggingMixin):
         default_var: Any = __NO_DEFAULT_SENTINEL,
         deserialize_json: bool = False,
         team_name: str | None = None,
+        *,
+        session: Session | None = None,
     ) -> Any:
         """
         Get a value for an Airflow Variable Key.
@@ -164,6 +180,7 @@ class Variable(Base, LoggingMixin):
         :param default_var: Default value of the Variable if the Variable 
doesn't exist
         :param deserialize_json: Deserialize the value to a Python dict
         :param team_name: Team name associated to the task trying to access 
the variable (if any)
+        :param session: Existing SQLAlchemy Session. Callers holding an open 
transaction must pass it.
         """
         # TODO: This is not the best way of having compat, but it's "better 
than erroring" for now. This still
         # means SQLA etc is loaded, but we can't avoid that unless/until we 
add import shims as a big
@@ -172,6 +189,11 @@ class Variable(Base, LoggingMixin):
         # If this is set it means we are in some kind of execution context 
(Task, Dag Parse or Triggerer perhaps)
         # and should use the Task SDK API server path
         if hasattr(sys.modules.get("airflow.sdk.execution_time.task_runner"), 
"SUPERVISOR_COMMS"):
+            if session is not None:
+                raise ValueError(
+                    "Variable.get() cannot use a metadata database session 
from an execution context; "
+                    "reads there go through the Execution API. Use 
airflow.sdk.Variable.get() instead."
+                )
             warnings.warn(
                 "Using Variable.get from `airflow.models` is deprecated."
                 "Please use `get` on Variable from sdk(`airflow.sdk.Variable`) 
instead",
@@ -192,7 +214,7 @@ class Variable(Base, LoggingMixin):
                 "Multi-team mode is not configured in the Airflow environment 
but the task trying to access the variable belongs to a team"
             )
 
-        var_val = Variable.get_variable_from_secrets(key=key, 
team_name=team_name)
+        var_val = Variable.get_variable_from_secrets(key=key, 
team_name=team_name, session=session)
         if var_val is None:
             if default_var is not cls.__NO_DEFAULT_SENTINEL:
                 return default_var
@@ -344,7 +366,7 @@ class Variable(Base, LoggingMixin):
 
         Variable.check_for_write_conflict(key=key, team_name=team_name)
 
-        if Variable.get_variable_from_secrets(key=key, team_name=team_name) is 
None:
+        if Variable.get_variable_from_secrets(key=key, team_name=team_name, 
session=session) is None:
             raise KeyError(f"Variable {key} does not exist")
 
         ctx: contextlib.AbstractContextManager
@@ -469,12 +491,18 @@ class Variable(Base, LoggingMixin):
         return None
 
     @staticmethod
-    def get_variable_from_secrets(key: str, team_name: str | None = None) -> 
str | None:
+    def get_variable_from_secrets(
+        key: str, team_name: str | None = None, *, session: Session | None = 
None
+    ) -> str | None:
         """
         Get Airflow Variable by iterating over all Secret Backends.
 
         :param key: Variable Key
         :param team_name: Team name associated to the task trying to access 
the variable (if any)
+        :param session: Existing session to reuse for the metadata database 
lookup. Callers that
+            already hold a transaction must pass it, otherwise 
``MetastoreBackend`` opens the same
+            scoped session and commits it, which detaches the caller's objects 
and is rejected
+            outright under ``prohibit_commit``.
         :return: Variable Value
         """
         from airflow.sdk import SecretCache
@@ -489,9 +517,23 @@ class Variable(Base, LoggingMixin):
         var_val = None
         # iterate over backends if not in cache (or expired)
         for secrets_backend in ensure_secrets_loaded():
+            # Only the metastore Variable backend touches the metadata 
database, so it is the only
+            # one offered a session, and only when its own override accepts 
one.  A subclass that
+            # overrides get_variable without the parameter raises TypeError, 
which the handler
+            # below swallows into a false "not found" that then gets cached.
+            session_kwargs: dict[str, Session] = {}
+            if (
+                session is not None
+                and isinstance(secrets_backend, MetastoreBackend)
+                and accepts_kwarg(secrets_backend.get_variable, "session")
+            ):
+                session_kwargs["session"] = session
             try:
                 var_val = call_secrets_backend_method(
-                    secrets_backend.get_variable, team_name=team_name, key=key
+                    secrets_backend.get_variable,
+                    team_name=team_name,
+                    key=key,
+                    **session_kwargs,
                 )
                 if var_val is not None:
                     break
diff --git a/airflow-core/src/airflow/serialization/definitions/dag.py 
b/airflow-core/src/airflow/serialization/definitions/dag.py
index 5d5c4763622..e6979fc44f3 100644
--- a/airflow-core/src/airflow/serialization/definitions/dag.py
+++ b/airflow-core/src/airflow/serialization/definitions/dag.py
@@ -763,7 +763,7 @@ class SerializedDAG:
             interval = deserialized_deadline_alert.interval
 
             if isinstance(interval, SerializedVariableInterval):
-                interval = interval.resolve()
+                interval = interval.resolve(session=session)
 
             if isinstance(deserialized_deadline_alert.reference, 
SerializedReferenceModels.TYPES.DAGRUN):
                 deadline_time = 
deserialized_deadline_alert.reference.evaluate_with(
diff --git a/airflow-core/src/airflow/serialization/definitions/deadline.py 
b/airflow-core/src/airflow/serialization/definitions/deadline.py
index 5f462c63d1a..e4362338cc0 100644
--- a/airflow-core/src/airflow/serialization/definitions/deadline.py
+++ b/airflow-core/src/airflow/serialization/definitions/deadline.py
@@ -388,10 +388,14 @@ class SerializedVariableInterval:
 
     key: str
 
-    def resolve(self) -> timedelta:
+    def resolve(self, *, session: Session | None = None) -> timedelta:
+        """
+        Get the Airflow Variable and return it as a ``timedelta``.
 
+        :param session: Existing SQLAlchemy Session. Callers holding an open 
transaction must pass it.
+        """
         try:
-            value = Variable.get(self.key)
+            value = Variable.get(self.key, session=session)
         except KeyError as e:
             raise ValueError(f"VariableInterval '{self.key}' not found") from e
 
diff --git a/airflow-core/tests/unit/models/test_variable.py 
b/airflow-core/tests/unit/models/test_variable.py
index cc2f9f94b93..57742aff4fa 100644
--- a/airflow-core/tests/unit/models/test_variable.py
+++ b/airflow-core/tests/unit/models/test_variable.py
@@ -30,6 +30,7 @@ from airflow.models import Variable, crypto, variable
 from airflow.sdk import SecretCache
 from airflow.secrets import BaseSecretsBackend
 from airflow.secrets.metastore import MetastoreBackend
+from airflow.utils.sqlalchemy import prohibit_commit
 
 from tests_common.test_utils import db
 from tests_common.test_utils.config import conf_vars
@@ -66,6 +67,29 @@ class _TeamAwareVariableBackend(BaseSecretsBackend):
         return "secret_val"
 
 
+class _SessionUnawareMetastoreBackend(MetastoreBackend):
+    """A custom backend whose ``get_variable`` override predates the 
``session`` keyword."""
+
+    # The signature mismatch with the base class is the point of this fixture, 
so mypy's
+    # override check has to be waived here rather than fixed.
+    def get_variable(self, key: str, team_name: str | None = None) -> str | 
None:  # type: ignore[override]
+        return "from_subclass"
+
+
+class _SessionAwareMetastoreBackend(MetastoreBackend):
+    """A custom backend whose ``get_variable`` override does accept 
``session``."""
+
+    def __init__(self):
+        super().__init__()
+        self.received_session: Session | None = None
+
+    def get_variable(
+        self, key: str, team_name: str | None = None, *, session: Session | 
None = None
+    ) -> str | None:
+        self.received_session = session
+        return "from_subclass"
+
+
 class TestVariable:
     @pytest.fixture(autouse=True)
     def setup_test_cases(self):
@@ -254,6 +278,100 @@ class TestVariable:
 
         assert mock_check.call_args.kwargs["team_name"] == testing_team.name
 
+    @mock.patch.object(MetastoreBackend, "get_variable", autospec=True)
+    @mock.patch("airflow.models.variable.ensure_secrets_loaded")
+    def test_get_forwards_session_to_metastore_backend(self, 
mock_ensure_secrets, mock_get_variable, session):
+        mock_get_variable.return_value = "from_db"
+        mock_ensure_secrets.return_value = [MetastoreBackend()]
+
+        assert Variable.get("some_key", session=session) == "from_db"
+        assert mock_get_variable.call_args.kwargs["session"] is session
+
+    @mock.patch.object(MetastoreBackend, "get_variable", autospec=True)
+    @mock.patch("airflow.models.variable.ensure_secrets_loaded")
+    def test_get_without_session_omits_session_kwarg(self, 
mock_ensure_secrets, mock_get_variable):
+        mock_get_variable.return_value = "from_db"
+        mock_ensure_secrets.return_value = [MetastoreBackend()]
+
+        assert Variable.get("some_key") == "from_db"
+        assert "session" not in mock_get_variable.call_args.kwargs
+
+    @mock.patch("airflow.models.variable.ensure_secrets_loaded")
+    def test_get_does_not_forward_session_to_other_backends(self, 
mock_ensure_secrets, session):
+        """Only the metastore backend reads the metadata database, so only it 
accepts a session."""
+        mock_backend = mock.Mock()
+        mock_backend.get_variable.return_value = "from_backend"
+        mock_backend.__class__.__name__ = "MockSecretsBackend"
+        mock_ensure_secrets.return_value = [mock_backend]
+
+        assert Variable.get("some_key", session=session) == "from_backend"
+        assert "session" not in mock_backend.get_variable.call_args.kwargs
+
+    @mock.patch("airflow.models.variable.ensure_secrets_loaded")
+    def test_get_omits_session_for_session_unaware_metastore_subclass(self, 
mock_ensure_secrets, session):
+        """Forwarding a session to an override that predates it would read as 
a missing Variable."""
+        mock_ensure_secrets.return_value = [_SessionUnawareMetastoreBackend()]
+
+        assert Variable.get("some_key", session=session) == "from_subclass"
+
+    @mock.patch("airflow.models.variable.ensure_secrets_loaded")
+    def test_get_forwards_session_to_session_aware_metastore_subclass(self, 
mock_ensure_secrets, session):
+        """A subclass that does accept a session still receives it, so it 
reuses the transaction."""
+        backend = _SessionAwareMetastoreBackend()
+        mock_ensure_secrets.return_value = [backend]
+
+        assert Variable.get("some_key", session=session) == "from_subclass"
+        assert backend.received_session is session
+
+    def test_get_with_session_does_not_commit_under_prohibit_commit(self, 
session):
+        """
+        A caller holding an open transaction can read a Variable without its 
session being committed.
+
+        Without the session being forwarded, 
``MetastoreBackend.get_variable``'s ``provide_session``
+        takes the same scoped session and commits it, which the guard rejects.
+        """
+        Variable.set(key="interval_key", value="60", session=session)
+        session.commit()
+        SecretCache.invalidate_variable("interval_key")
+
+        with prohibit_commit(session):
+            assert Variable.get("interval_key", session=session) == "60"
+
+    def test_update_with_session_does_not_commit_under_prohibit_commit(self, 
session):
+        """``update`` verifies existence through the secrets chain, which must 
reuse the session too."""
+        Variable.set(key="interval_key", value="60", session=session)
+        session.commit()
+        SecretCache.invalidate_variable("interval_key")
+
+        with prohibit_commit(session):
+            Variable.update(key="interval_key", value="120", session=session)
+
+    def 
test_setdefault_with_session_does_not_commit_under_prohibit_commit(self, 
session):
+        """``setdefault`` reads through the secrets chain before deciding 
whether to write."""
+        Variable.set(key="interval_key", value="60", session=session)
+        session.commit()
+        SecretCache.invalidate_variable("interval_key")
+
+        with prohibit_commit(session):
+            assert Variable.setdefault("interval_key", "120", session=session) 
== "60"
+
+    def 
test_setdefault_writes_default_with_session_under_prohibit_commit(self, 
session):
+        """The write half must reuse the session too, so the miss path stays 
inside the transaction."""
+        with prohibit_commit(session):
+            assert Variable.setdefault("absent_key", "30", session=session) == 
"30"
+        session.commit()
+
+        assert Variable.get("absent_key", session=session) == "30"
+
+    def test_get_rejects_session_in_execution_context(self):
+        """Reads from an execution context go via the Execution API, where a 
session is meaningless."""
+        task_runner = mock.Mock(SUPERVISOR_COMMS=mock.Mock())
+        with (
+            mock.patch.dict("sys.modules", 
{"airflow.sdk.execution_time.task_runner": task_runner}),
+            pytest.raises(ValueError, match="cannot use a metadata database 
session"),
+        ):
+            Variable.get("some_key", session=mock.Mock())
+
     def test_variable_set_get_round_trip_json(self):
         value = {"a": 17, "b": 47}
         Variable.set(key="tested_var_set_id", value=value, serialize_json=True)
diff --git a/airflow-core/tests/unit/serialization/definitions/test_deadline.py 
b/airflow-core/tests/unit/serialization/definitions/test_deadline.py
index 3e12fe0830c..f0fad39a37e 100644
--- a/airflow-core/tests/unit/serialization/definitions/test_deadline.py
+++ b/airflow-core/tests/unit/serialization/definitions/test_deadline.py
@@ -43,6 +43,18 @@ class TestVariableInterval:
 
         assert interval.resolve() == expected
 
+    def test_resolve_forwards_session(self, mocker):
+        """The scheduler resolves intervals while holding an open transaction, 
so the caller's
+        session has to reach ``Variable.get`` rather than open a new 
session."""
+        expected_seconds = 42
+        mock_get = mocker.patch.object(Variable, "get", 
return_value=str(expected_seconds))
+        session = mocker.MagicMock()
+
+        interval = SerializedVariableInterval(key="test_interval")
+
+        assert interval.resolve(session=session) == 
timedelta(seconds=expected_seconds)
+        mock_get.assert_called_once_with("test_interval", session=session)
+
     @pytest.mark.parametrize(
         ("value", "raise_missing", "match"),
         [
diff --git a/shared/secrets_backend/src/airflow_shared/secrets_backend/base.py 
b/shared/secrets_backend/src/airflow_shared/secrets_backend/base.py
index 77c08be45f1..d98829c118e 100644
--- a/shared/secrets_backend/src/airflow_shared/secrets_backend/base.py
+++ b/shared/secrets_backend/src/airflow_shared/secrets_backend/base.py
@@ -21,24 +21,23 @@ from abc import ABC
 from collections.abc import Callable
 
 
-def _accepts_team_name(method: Callable) -> bool:
+def accepts_kwarg(method: Callable, name: str) -> bool:
     """
-    Return whether a secrets-backend method accepts the ``team_name`` keyword.
-
-    Backends written before Airflow 3.2 override ``get_conn_value`` / 
``get_variable`` /
-    ``get_connection`` with the legacy ``(self, conn_id)`` / ``(self, key)`` 
signature.
-    AIP-67 (multi-team) added a ``team_name`` keyword; forwarding it to those 
raises
-    ``TypeError``. A method accepts it if it declares a ``team_name`` 
parameter or a
-    ``**kwargs`` catch-all.
+    Return whether a secrets-backend method accepts the keyword *name*.
+
+    Backends override ``get_conn_value`` / ``get_variable`` / 
``get_connection`` with
+    whatever signature was current when they were written, so a keyword added 
later cannot
+    be forwarded blindly: it raises ``TypeError`` inside the backend, which 
callers swallow
+    per-backend and report as a missing secret.  ``team_name`` (added by 
AIP-67 in 3.2) is
+    one example.  A method is considered to accept *name* if it explicitly 
declares the
+    parameter or has a ``**kwargs`` catch-all.
     """
     try:
         parameters = inspect.signature(method).parameters
     except (TypeError, ValueError):
-        # Un-introspectable callable (e.g. C-implemented): assume the 3.2+ 
signature.
+        # Un-introspectable callable (e.g. C-implemented): assume the current 
signature.
         return True
-    return "team_name" in parameters or any(
-        p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()
-    )
+    return name in parameters or any(p.kind is inspect.Parameter.VAR_KEYWORD 
for p in parameters.values())
 
 
 def call_secrets_backend_method(method: Callable, *, team_name: str | None, 
**kwargs):
@@ -52,7 +51,7 @@ def call_secrets_backend_method(method: Callable, *, 
team_name: str | None, **kw
     rather than retried without ``team_name``, which could mask the error and 
resolve a
     team-scoped lookup against the global scope.
     """
-    if _accepts_team_name(method):
+    if accepts_kwarg(method, "team_name"):
         return method(team_name=team_name, **kwargs)
     return method(**kwargs)
 
diff --git a/shared/secrets_backend/tests/secrets_backend/test_base.py 
b/shared/secrets_backend/tests/secrets_backend/test_base.py
index e374a883cc4..60587892d8e 100644
--- a/shared/secrets_backend/tests/secrets_backend/test_base.py
+++ b/shared/secrets_backend/tests/secrets_backend/test_base.py
@@ -19,7 +19,7 @@ from __future__ import annotations
 
 import pytest
 
-from airflow_shared.secrets_backend.base import BaseSecretsBackend
+from airflow_shared.secrets_backend.base import BaseSecretsBackend, 
accepts_kwarg
 
 
 class MockConnection:
@@ -243,3 +243,26 @@ class TestTeamNameBackwardCompat:
         backend = _TeamUnawareConnValueBackend(conn_values={})
 
         assert backend.get_connection(conn_id="missing", team_name=team_name) 
is None
+
+
+class TestAcceptsKwarg:
+    @pytest.mark.parametrize(
+        ("method", "name", "expected"),
+        [
+            (lambda key: None, "session", False),
+            (lambda key, session=None: None, "session", True),
+            (lambda key, **kwargs: None, "session", True),
+            (lambda conn_id: None, "team_name", False),
+            (lambda conn_id, team_name=None: None, "team_name", True),
+            (lambda conn_id, **kwargs: None, "team_name", True),
+        ],
+    )
+    def test_declared_parameter_or_kwargs_catch_all(self, method, name, 
expected):
+        assert accepts_kwarg(method, name) is expected
+
+    def test_each_keyword_judged_independently(self):
+        def get_variable(key, team_name=None):
+            return None
+
+        assert accepts_kwarg(get_variable, "team_name") is True
+        assert accepts_kwarg(get_variable, "session") is False

Reply via email to