Skip to content

Commit 7e22395

Browse files
authored
Add session param to BaseStateBackend interface to fix custom backends (#66708)
1 parent 120dbed commit 7e22395

5 files changed

Lines changed: 113 additions & 44 deletions

File tree

airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_state.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def get_asset_state_by_name(
8787
) -> AssetStateResponse:
8888
"""Get an asset state value by asset name."""
8989
asset_id = _resolve_asset_id_by_name(name, session)
90-
value = get_state_backend().get(AssetScope(asset_id=asset_id), key, session=session) # type: ignore[call-arg] # @provide_session adds session kwarg at runtime; BaseStateBackend signature omits it so mypy can't see it
90+
value = get_state_backend().get(AssetScope(asset_id=asset_id), key, session=session)
9191
if value is None:
9292
raise HTTPException(
9393
status_code=status.HTTP_404_NOT_FOUND,
@@ -105,7 +105,7 @@ def set_asset_state_by_name(
105105
) -> None:
106106
"""Set an asset state value by asset name."""
107107
asset_id = _resolve_asset_id_by_name(name, session)
108-
get_state_backend().set(AssetScope(asset_id=asset_id), key, body.value, session=session) # type: ignore[call-arg] # @provide_session adds session kwarg at runtime; BaseStateBackend signature omits it so mypy can't see it
108+
get_state_backend().set(AssetScope(asset_id=asset_id), key, body.value, session=session)
109109

110110

111111
@router.delete("/by-name/value", status_code=status.HTTP_204_NO_CONTENT)
@@ -116,7 +116,7 @@ def delete_asset_state_by_name(
116116
) -> None:
117117
"""Delete a single asset state key by asset name."""
118118
asset_id = _resolve_asset_id_by_name(name, session)
119-
get_state_backend().delete(AssetScope(asset_id=asset_id), key, session=session) # type: ignore[call-arg] # @provide_session adds session kwarg at runtime; BaseStateBackend signature omits it so mypy can't see it
119+
get_state_backend().delete(AssetScope(asset_id=asset_id), key, session=session)
120120

121121

122122
@router.delete("/by-name/clear", status_code=status.HTTP_204_NO_CONTENT)
@@ -126,7 +126,7 @@ def clear_asset_state_by_name(
126126
) -> None:
127127
"""Delete all state keys for an asset by asset name."""
128128
asset_id = _resolve_asset_id_by_name(name, session)
129-
get_state_backend().clear(AssetScope(asset_id=asset_id), session=session) # type: ignore[call-arg] # @provide_session adds session kwarg at runtime; BaseStateBackend signature omits it so mypy can't see it
129+
get_state_backend().clear(AssetScope(asset_id=asset_id), session=session)
130130

131131

132132
@router.get("/by-uri/value")
@@ -137,7 +137,7 @@ def get_asset_state_by_uri(
137137
) -> AssetStateResponse:
138138
"""Get an asset state value by asset URI."""
139139
asset_id = _resolve_asset_id_by_uri(uri, session)
140-
value = get_state_backend().get(AssetScope(asset_id=asset_id), key, session=session) # type: ignore[call-arg] # @provide_session adds session kwarg at runtime; BaseStateBackend signature omits it so mypy can't see it
140+
value = get_state_backend().get(AssetScope(asset_id=asset_id), key, session=session)
141141
if value is None:
142142
raise HTTPException(
143143
status_code=status.HTTP_404_NOT_FOUND,
@@ -155,7 +155,7 @@ def set_asset_state_by_uri(
155155
) -> None:
156156
"""Set an asset state value by asset URI."""
157157
asset_id = _resolve_asset_id_by_uri(uri, session)
158-
get_state_backend().set(AssetScope(asset_id=asset_id), key, body.value, session=session) # type: ignore[call-arg] # @provide_session adds session kwarg at runtime; BaseStateBackend signature omits it so mypy can't see it
158+
get_state_backend().set(AssetScope(asset_id=asset_id), key, body.value, session=session)
159159

160160

161161
@router.delete("/by-uri/value", status_code=status.HTTP_204_NO_CONTENT)
@@ -166,7 +166,7 @@ def delete_asset_state_by_uri(
166166
) -> None:
167167
"""Delete a single asset state key by asset URI."""
168168
asset_id = _resolve_asset_id_by_uri(uri, session)
169-
get_state_backend().delete(AssetScope(asset_id=asset_id), key, session=session) # type: ignore[call-arg] # @provide_session adds session kwarg at runtime; BaseStateBackend signature omits it so mypy can't see it
169+
get_state_backend().delete(AssetScope(asset_id=asset_id), key, session=session)
170170

171171

172172
@router.delete("/by-uri/clear", status_code=status.HTTP_204_NO_CONTENT)
@@ -176,4 +176,4 @@ def clear_asset_state_by_uri(
176176
) -> None:
177177
"""Delete all state keys for an asset by asset URI."""
178178
asset_id = _resolve_asset_id_by_uri(uri, session)
179-
get_state_backend().clear(AssetScope(asset_id=asset_id), session=session) # type: ignore[call-arg] # @provide_session adds session kwarg at runtime; BaseStateBackend signature omits it so mypy can't see it
179+
get_state_backend().clear(AssetScope(asset_id=asset_id), session=session)

airflow-core/src/airflow/api_fastapi/execution_api/routes/task_state.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def get_task_state(
6565
) -> TaskStateResponse:
6666
"""Get value for a task state."""
6767
scope = _get_task_scope_for_ti(task_instance_id, session)
68-
value = get_state_backend().get(scope, key, session=session) # type: ignore[call-arg] # @provide_session adds session kwarg at runtime; BaseStateBackend signature omits it so mypy can't see it
68+
value = get_state_backend().get(scope, key, session=session)
6969
if value is None:
7070
raise HTTPException(
7171
status_code=status.HTTP_404_NOT_FOUND,
@@ -86,7 +86,7 @@ def set_task_state(
8686
) -> None:
8787
"""Set a task state key, creating or updating the row."""
8888
scope = _get_task_scope_for_ti(task_instance_id, session)
89-
get_state_backend().set(scope, key, body.value, session=session) # type: ignore[call-arg] # @provide_session adds session kwarg at runtime; BaseStateBackend signature omits it so mypy can't see it
89+
get_state_backend().set(scope, key, body.value, session=session)
9090

9191

9292
@router.delete("/{task_instance_id}/{key}", status_code=status.HTTP_204_NO_CONTENT)
@@ -97,7 +97,7 @@ def delete_task_state(
9797
) -> None:
9898
"""Delete a single task state key."""
9999
scope = _get_task_scope_for_ti(task_instance_id, session)
100-
get_state_backend().delete(scope, key, session=session) # type: ignore[call-arg] # @provide_session adds session kwarg at runtime; BaseStateBackend signature omits it so mypy can't see it
100+
get_state_backend().delete(scope, key, session=session)
101101

102102

103103
@router.delete("/{task_instance_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -125,4 +125,4 @@ def clear_task_state(
125125
accepted without error.
126126
"""
127127
scope = _get_task_scope_for_ti(task_instance_id, session)
128-
get_state_backend().clear(scope, all_map_indices=all_map_indices, session=session) # type: ignore[call-arg] # @provide_session adds session kwarg at runtime; BaseStateBackend signature omits it so mypy can't see it
128+
get_state_backend().clear(scope, all_map_indices=all_map_indices, session=session)

airflow-core/src/airflow/state/metastore.py

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
# under the License.
1818
from __future__ import annotations
1919

20+
from collections.abc import AsyncGenerator
21+
from contextlib import asynccontextmanager
2022
from typing import TYPE_CHECKING
2123

2224
from sqlalchemy import delete, select
@@ -38,6 +40,16 @@
3840
from sqlalchemy.orm import Session
3941

4042

43+
@asynccontextmanager
44+
async def _async_session(session: AsyncSession | None) -> AsyncGenerator[AsyncSession, None]:
45+
"""Use provided async session or create a new one."""
46+
if session is not None:
47+
yield session
48+
else:
49+
async with create_session_async() as s:
50+
yield s
51+
52+
4153
def _build_upsert_stmt(
4254
dialect: str | None,
4355
model: type,
@@ -69,7 +81,9 @@ class MetastoreStateBackend(BaseStateBackend):
6981
"""Default state backend for tasks and assets. Stores task and asset state in the Airflow metadata database."""
7082

7183
@provide_session
72-
def get(self, scope: StateScope, key: str, *, session: Session = NEW_SESSION) -> str | None:
84+
def get(self, scope: StateScope, key: str, *, session: Session | None = NEW_SESSION) -> str | None:
85+
if TYPE_CHECKING:
86+
assert session is not None
7387
match scope:
7488
case TaskScope():
7589
return self._get_task_state(scope, key, session=session)
@@ -79,7 +93,9 @@ def get(self, scope: StateScope, key: str, *, session: Session = NEW_SESSION) ->
7993
assert_never(scope)
8094

8195
@provide_session
82-
def set(self, scope: StateScope, key: str, value: str, *, session: Session = NEW_SESSION) -> None:
96+
def set(self, scope: StateScope, key: str, value: str, *, session: Session | None = NEW_SESSION) -> None:
97+
if TYPE_CHECKING:
98+
assert session is not None
8399
match scope:
84100
case TaskScope():
85101
self._set_task_state(scope, key, value, session=session)
@@ -89,7 +105,9 @@ def set(self, scope: StateScope, key: str, value: str, *, session: Session = NEW
89105
assert_never(scope)
90106

91107
@provide_session
92-
def delete(self, scope: StateScope, key: str, *, session: Session = NEW_SESSION) -> None:
108+
def delete(self, scope: StateScope, key: str, *, session: Session | None = NEW_SESSION) -> None:
109+
if TYPE_CHECKING:
110+
assert session is not None
93111
match scope:
94112
case TaskScope():
95113
self._delete_task_state(scope, key, session=session)
@@ -104,8 +122,10 @@ def clear(
104122
scope: StateScope,
105123
*,
106124
all_map_indices: bool = False,
107-
session: Session = NEW_SESSION,
125+
session: Session | None = NEW_SESSION,
108126
) -> None:
127+
if TYPE_CHECKING:
128+
assert session is not None
109129
match scope:
110130
case TaskScope():
111131
self._clear_task_state(scope, all_map_indices=all_map_indices, session=session)
@@ -114,43 +134,47 @@ def clear(
114134
case _:
115135
assert_never(scope)
116136

117-
async def aget(self, scope: StateScope, key: str) -> str | None:
118-
async with create_session_async() as session:
137+
async def aget(self, scope: StateScope, key: str, *, session: AsyncSession | None = None) -> str | None:
138+
async with _async_session(session) as s:
119139
match scope:
120140
case TaskScope():
121-
return await self._aget_task_state(scope, key, session=session)
141+
return await self._aget_task_state(scope, key, session=s)
122142
case AssetScope():
123-
return await self._aget_asset_state(scope, key, session=session)
143+
return await self._aget_asset_state(scope, key, session=s)
124144
case _:
125145
assert_never(scope)
126146

127-
async def aset(self, scope: StateScope, key: str, value: str) -> None:
128-
async with create_session_async() as session:
147+
async def aset(
148+
self, scope: StateScope, key: str, value: str, *, session: AsyncSession | None = None
149+
) -> None:
150+
async with _async_session(session) as s:
129151
match scope:
130152
case TaskScope():
131-
await self._aset_task_state(scope, key, value, session=session)
153+
await self._aset_task_state(scope, key, value, session=s)
132154
case AssetScope():
133-
await self._aset_asset_state(scope, key, value, session=session)
155+
await self._aset_asset_state(scope, key, value, session=s)
134156
case _:
135157
assert_never(scope)
136158

137-
async def adelete(self, scope: StateScope, key: str) -> None:
138-
async with create_session_async() as session:
159+
async def adelete(self, scope: StateScope, key: str, *, session: AsyncSession | None = None) -> None:
160+
async with _async_session(session) as s:
139161
match scope:
140162
case TaskScope():
141-
await self._adelete_task_state(scope, key, session=session)
163+
await self._adelete_task_state(scope, key, session=s)
142164
case AssetScope():
143-
await self._adelete_asset_state(scope, key, session=session)
165+
await self._adelete_asset_state(scope, key, session=s)
144166
case _:
145167
assert_never(scope)
146168

147-
async def aclear(self, scope: StateScope, *, all_map_indices: bool = False) -> None:
148-
async with create_session_async() as session:
169+
async def aclear(
170+
self, scope: StateScope, *, all_map_indices: bool = False, session: AsyncSession | None = None
171+
) -> None:
172+
async with _async_session(session) as s:
149173
match scope:
150174
case TaskScope():
151-
await self._aclear_task_state(scope, all_map_indices=all_map_indices, session=session)
175+
await self._aclear_task_state(scope, all_map_indices=all_map_indices, session=s)
152176
case AssetScope():
153-
await self._aclear_asset_state(scope, session=session)
177+
await self._aclear_asset_state(scope, session=s)
154178
case _:
155179
assert_never(scope)
156180

airflow-core/tests/unit/state/test_metastore.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from airflow.models.task_state import TaskStateModel
2929
from airflow.state import AssetScope, TaskScope, resolve_state_backend
3030
from airflow.state.metastore import MetastoreStateBackend
31-
from airflow.utils.session import create_session
31+
from airflow.utils.session import create_session, create_session_async
3232

3333
from tests_common.test_utils.config import conf_vars
3434
from tests_common.test_utils.db import clear_db_assets, clear_db_dags, clear_db_runs
@@ -379,6 +379,16 @@ async def test_aset_task_raises_for_missing_dag_run(self, backend: MetastoreStat
379379
with pytest.raises(ValueError, match="No DagRun found"):
380380
await backend.aset(scope, "job_id", "app_async")
381381

382+
async def test_aset_and_aget_with_provided_session(
383+
self, backend: MetastoreStateBackend, dag_run_committed: DagRun
384+
):
385+
"""async methods use a provided AsyncSession when one is given."""
386+
scope = TaskScope(dag_id=DAG_ID, run_id=RUN_ID, task_id=TASK_ID)
387+
async with create_session_async() as session:
388+
await backend.aset(scope, "job_id", "app_with_session", session=session)
389+
result = await backend.aget(scope, "job_id", session=session)
390+
assert result == "app_with_session"
391+
382392

383393
class TestResolveStateBackend:
384394
@conf_vars({("state_store", "backend"): "airflow.state.metastore.MetastoreStateBackend"})

shared/state/src/airflow_shared/state/__init__.py

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818

1919
from abc import ABC, abstractmethod
2020
from dataclasses import dataclass
21+
from typing import TYPE_CHECKING
22+
23+
if TYPE_CHECKING:
24+
from sqlalchemy.ext.asyncio import AsyncSession
25+
from sqlalchemy.orm import Session
2126

2227

2328
@dataclass(frozen=True)
@@ -62,34 +67,42 @@ class BaseStateBackend(ABC):
6267
... # asset-specific storage
6368
6469
Custom backends are configured via ``[state_store] backend`` in ``airflow.cfg``.
70+
71+
**The ``session`` parameter on ``get``, ``set``, ``delete``, and ``clear``:**
72+
73+
The default ``MetastoreStateBackend`` passes a SQLAlchemy ``Session`` through
74+
these methods. Custom backends that do not use SQLAlchemy should accept ``session`` as a
75+
keyword argument and ignore it.
6576
"""
6677

6778
@abstractmethod
68-
def get(self, scope: StateScope, key: str) -> str | None:
79+
def get(self, scope: StateScope, key: str, *, session: Session | None = None) -> str | None:
6980
"""
7081
Return the stored value, or None if the key does not exist.
7182
7283
Must handle both ``TaskScope`` and ``AssetScope``.
7384
"""
7485

7586
@abstractmethod
76-
def set(self, scope: StateScope, key: str, value: str) -> None:
87+
def set(self, scope: StateScope, key: str, value: str, *, session: Session | None = None) -> None:
7788
"""
7889
Write or overwrite the value for the given key.
7990
8091
Must handle both ``TaskScope`` and ``AssetScope``.
8192
"""
8293

8394
@abstractmethod
84-
def delete(self, scope: StateScope, key: str) -> None:
95+
def delete(self, scope: StateScope, key: str, *, session: Session | None = None) -> None:
8596
"""
8697
Delete a single key. No-op if the key does not exist.
8798
8899
Must handle both ``TaskScope`` and ``AssetScope``.
89100
"""
90101

91102
@abstractmethod
92-
def clear(self, scope: StateScope, *, all_map_indices: bool = False) -> None:
103+
def clear(
104+
self, scope: StateScope, *, all_map_indices: bool = False, session: Session | None = None
105+
) -> None:
93106
"""
94107
Delete all keys under the given scope.
95108
@@ -102,23 +115,45 @@ def clear(self, scope: StateScope, *, all_map_indices: bool = False) -> None:
102115
"""
103116

104117
@abstractmethod
105-
async def aget(self, scope: StateScope, key: str) -> str | None:
106-
"""Async variant of get. Must handle both ``TaskScope`` and ``AssetScope``."""
118+
async def aget(self, scope: StateScope, key: str, *, session: AsyncSession | None = None) -> str | None:
119+
"""
120+
Async variant of get. Must handle both ``TaskScope`` and ``AssetScope``.
121+
122+
``session`` is optional. If provided, implementations should use it directly.
123+
If ``None``, implementations manage their own async session internally.
124+
"""
107125

108126
@abstractmethod
109-
async def aset(self, scope: StateScope, key: str, value: str) -> None:
110-
"""Async variant of set. Must handle both ``TaskScope`` and ``AssetScope``."""
127+
async def aset(
128+
self, scope: StateScope, key: str, value: str, *, session: AsyncSession | None = None
129+
) -> None:
130+
"""
131+
Async variant of set. Must handle both ``TaskScope`` and ``AssetScope``.
132+
133+
``session`` is optional. If provided, implementations should use it directly.
134+
If ``None``, implementations manage their own async session internally.
135+
"""
111136

112137
@abstractmethod
113-
async def adelete(self, scope: StateScope, key: str) -> None:
114-
"""Async variant of delete. Must handle both ``TaskScope`` and ``AssetScope``."""
138+
async def adelete(self, scope: StateScope, key: str, *, session: AsyncSession | None = None) -> None:
139+
"""
140+
Async variant of delete. Must handle both ``TaskScope`` and ``AssetScope``.
141+
142+
``session`` is optional. If provided, implementations should use it directly.
143+
If ``None``, implementations manage their own async session internally.
144+
"""
115145

116146
@abstractmethod
117-
async def aclear(self, scope: StateScope, *, all_map_indices: bool = False) -> None:
147+
async def aclear(
148+
self, scope: StateScope, *, all_map_indices: bool = False, session: AsyncSession | None = None
149+
) -> None:
118150
"""
119151
Async variant of clear. Must handle both ``TaskScope`` and ``AssetScope``.
120152
121153
For ``TaskScope``: by default, only keys for the exact ``map_index`` on the
122154
scope are cleared. Pass ``all_map_indices=True`` to wipe state across every
123155
mapped instance of the task. For ``AssetScope`` the flag has no effect.
156+
157+
``session`` is optional. If provided, implementations should use it directly.
158+
If ``None``, implementations manage their own async session internally.
124159
"""

0 commit comments

Comments
 (0)