This is an automated email from the ASF dual-hosted git repository.
jason810496 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 77c0f6e35a8 Implement async state store methods for object storage
backend (#72131)
77c0f6e35a8 is described below
commit 77c0f6e35a88443b7bc2474b8b28a92f7d2f7163
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Thu Sep 10 07:12:26 2026 +0800
Implement async state store methods for object storage backend (#72131)
* Implement async state store methods for object storage backend
* Document full async state store backend interface
---
.../task-and-asset-state-store.rst | 20 ++++++
.../providers/common/io/state_store/backend.py | 12 ++--
.../unit/common/io/state_store/test_backend.py | 84 ++++++++++++++++++++++
3 files changed, 112 insertions(+), 4 deletions(-)
diff --git
a/airflow-core/docs/administration-and-deployment/task-and-asset-state-store.rst
b/airflow-core/docs/administration-and-deployment/task-and-asset-state-store.rst
index 5ae6c0b1b33..04eb7174fed 100644
---
a/airflow-core/docs/administration-and-deployment/task-and-asset-state-store.rst
+++
b/airflow-core/docs/administration-and-deployment/task-and-asset-state-store.rst
@@ -138,6 +138,26 @@ Each method receives a ``scope`` argument that is either a
:class:`~airflow.sdk.
elif isinstance(scope, AssetScope):
return self._asset_store.get(scope, key)
+If the storage client is synchronous, implement the async methods by
offloading the sync work to a worker thread rather than calling it inline, so
callers on an event loop (``async`` tasks and watcher triggers) are not blocked:
+
+.. code-block:: python
+
+ import asyncio
+
+
+ class MyBackend(BaseStoreBackend):
+ async def aget(self, scope, key, *, session=None):
+ return await asyncio.to_thread(self.get, scope, key)
+
+ async def aset(self, scope, key, value, *, expires_at=None,
session=None):
+ await asyncio.to_thread(self.set, scope, key, value,
expires_at=expires_at, session=session)
+
+ async def adelete(self, scope, key, *, session=None):
+ await asyncio.to_thread(self.delete, scope, key, session=session)
+
+ async def aclear(self, scope, *, all_map_indices=False, session=None):
+ await asyncio.to_thread(self.clear, scope,
all_map_indices=all_map_indices, session=session)
+
:class:`~airflow.sdk.state.AssetScope` has three optional fields: ``asset_id``
(integer, server-side only), ``name``, and ``uri``. At least one must be set.
Server-side operations (REST API calls) provide ``asset_id``. Worker-side
operations provide ``name`` or ``uri`` (workers do not have access to the
integer ``asset_id``).
Configure the class via ``[state_store] backend``:
diff --git
a/providers/common/io/src/airflow/providers/common/io/state_store/backend.py
b/providers/common/io/src/airflow/providers/common/io/state_store/backend.py
index dd9ad29cafe..a6b7a690510 100644
--- a/providers/common/io/src/airflow/providers/common/io/state_store/backend.py
+++ b/providers/common/io/src/airflow/providers/common/io/state_store/backend.py
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+import asyncio
import json
from functools import cache
from typing import TYPE_CHECKING
@@ -187,8 +188,11 @@ class StateStoreObjectStorageBackend(BaseStoreBackend):
case _:
raise TypeError(f"Unknown scope type: {type(scope)}")
+ # fsspec is synchronous even for filesystems whose transport is async
underneath, so the
+ # a-prefixed methods offload to a worker thread to keep the caller's event
loop free.
+ # ``session`` is unused throughout: this backend never touches the
metastore.
async def aget(self, scope: StoreScope, key: str, *, session: AsyncSession
| None = None) -> str | None:
- raise NotImplementedError
+ return await asyncio.to_thread(self.get, scope, key)
async def aset(
self,
@@ -199,15 +203,15 @@ class StateStoreObjectStorageBackend(BaseStoreBackend):
expires_at: datetime | None = None,
session: AsyncSession | None = None,
) -> None:
- raise NotImplementedError
+ await asyncio.to_thread(self.set, scope, key, value,
expires_at=expires_at)
async def adelete(self, scope: StoreScope, key: str, *, session:
AsyncSession | None = None) -> None:
- raise NotImplementedError
+ await asyncio.to_thread(self.delete, scope, key)
async def aclear(
self, scope: StoreScope, *, all_map_indices: bool = False, session:
AsyncSession | None = None
) -> None:
- raise NotImplementedError
+ await asyncio.to_thread(self.clear, scope,
all_map_indices=all_map_indices)
def serialize_task_state_store_to_ref(self, *, value: JsonValue, key: str,
scope: TaskScope) -> str:
serialized = json.dumps(value)
diff --git
a/providers/common/io/tests/unit/common/io/state_store/test_backend.py
b/providers/common/io/tests/unit/common/io/state_store/test_backend.py
index 6e7c634b85f..f59c2e96441 100644
--- a/providers/common/io/tests/unit/common/io/state_store/test_backend.py
+++ b/providers/common/io/tests/unit/common/io/state_store/test_backend.py
@@ -16,6 +16,9 @@
# under the License.
from __future__ import annotations
+import threading
+from unittest import mock
+
import pytest
from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS
@@ -272,3 +275,84 @@ class TestStateStoreObjectStorageBackend:
backend._get_threshold.cache_clear()
with pytest.raises(ValueError, match="must be non-negative"):
backend._get_threshold()
+
+ @pytest.mark.asyncio
+ async def test_aset_and_aget_task(self, store, task_scope):
+ await store.aset(task_scope, "k", "hello")
+ assert await store.aget(task_scope, "k") == "hello"
+ # the async round-trip is visible to the sync API and vice versa
+ assert store.get(task_scope, "k") == "hello"
+
+ @pytest.mark.asyncio
+ async def test_aget_missing_returns_none(self, store, task_scope):
+ assert await store.aget(task_scope, "missing") is None
+
+ @pytest.mark.asyncio
+ async def test_adelete_task(self, store, task_scope):
+ store.set(task_scope, "k", "v")
+ await store.adelete(task_scope, "k")
+ assert store.get(task_scope, "k") is None
+
+ @pytest.mark.asyncio
+ async def test_adelete_missing_is_noop(self, store, task_scope):
+ await store.adelete(task_scope, "does_not_exist")
+
+ @pytest.mark.asyncio
+ async def test_aclear_task_single_map_index(self, store, task_scope):
+ store.set(task_scope, "k1", "v1")
+ store.set(task_scope, "k2", "v2")
+ await store.aclear(task_scope)
+ assert store.get(task_scope, "k1") is None
+ assert store.get(task_scope, "k2") is None
+
+ @pytest.mark.asyncio
+ async def test_aclear_task_all_map_indices(self, store):
+ scope0 = TaskScope(dag_id="d", run_id="r", task_id="t", map_index=0)
+ scope1 = TaskScope(dag_id="d", run_id="r", task_id="t", map_index=1)
+ store.set(scope0, "k", "v0")
+ store.set(scope1, "k", "v1")
+ await store.aclear(scope0, all_map_indices=True)
+ assert store.get(scope0, "k") is None
+ assert store.get(scope1, "k") is None
+
+ @pytest.mark.asyncio
+ async def test_adelete_asset(self, store, asset_scope):
+ store.set(asset_scope, "watermark", "2026-05-01")
+ await store.adelete(asset_scope, "watermark")
+ assert store.get(asset_scope, "watermark") is None
+
+ @pytest.mark.asyncio
+ async def test_aclear_asset(self, store, asset_scope):
+ store.set(asset_scope, "k1", "v1")
+ store.set(asset_scope, "k2", "v2")
+ await store.aclear(asset_scope)
+ assert store.get(asset_scope, "k1") is None
+ assert store.get(asset_scope, "k2") is None
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ ("async_call", "sync_method"),
+ [
+ (lambda store, scope: store.aget(scope, "k"), "get"),
+ (lambda store, scope: store.aset(scope, "k", "v"), "set"),
+ (lambda store, scope: store.adelete(scope, "k"), "delete"),
+ (lambda store, scope: store.aclear(scope), "clear"),
+ ],
+ ids=["aget", "aset", "adelete", "aclear"],
+ )
+ async def test_async_methods_run_blocking_work_off_the_loop_thread(
+ self, store, task_scope, async_call, sync_method
+ ):
+ loop_thread = threading.get_ident()
+ call_threads = []
+ original = getattr(store, sync_method)
+
+ def record_thread(*args, **kwargs):
+ call_threads.append(threading.get_ident())
+ return original(*args, **kwargs)
+
+ with mock.patch.object(store, sync_method, record_thread):
+ await async_call(store, task_scope)
+
+ assert call_threads
+ assert loop_thread not in call_threads