This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new 0d03ef4396d Add async asset store accessors for async tasks and
watcher triggers (#72127) (#72851)
0d03ef4396d is described below
commit 0d03ef4396d5d246db59d6e7b008f1d342fecaf7
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Thu Sep 10 18:57:57 2026 +0800
Add async asset store accessors for async tasks and watcher triggers
(#72127) (#72851)
* Add async asset store accessors for async tasks and watcher triggers
* Clarify async asset state accessor docstrings
* Parametrize async asset state accessor tests
(cherry picked from commit 2aa581ea02ba9c39a6c85560b1308f4cc09d6c9d)
---
.../docs/core-concepts/asset-state-store.rst | 25 ++-
task-sdk/src/airflow/sdk/execution_time/context.py | 127 ++++++++----
.../tests/task_sdk/execution_time/test_context.py | 225 ++++++++++++++++++++-
3 files changed, 333 insertions(+), 44 deletions(-)
diff --git a/airflow-core/docs/core-concepts/asset-state-store.rst
b/airflow-core/docs/core-concepts/asset-state-store.rst
index 3aa7ff7719f..ddb3fe93fb4 100644
--- a/airflow-core/docs/core-concepts/asset-state-store.rst
+++ b/airflow-core/docs/core-concepts/asset-state-store.rst
@@ -19,6 +19,7 @@
.. spelling:word-list::
+ accessors
subscripted
subscripting
@@ -101,7 +102,7 @@ If the task has more than one concrete inlet or outlet,
calling the shorthand ra
API reference
-------------
-The following methods are available on both the per-asset accessor
(``context["asset_state_store"][my_asset]``) and the shorthand
(``context["asset_state_store"]``) when the task has exactly one inlet.
+The following methods are available on both the per-asset accessor
(``context["asset_state_store"][my_asset]``) and the shorthand
(``context["asset_state_store"]``) when the task has exactly one inlet. Each
has an ``a``-prefixed async counterpart — ``aget``, ``aset``, ``adelete``,
``aclear`` — for use inside ``async`` tasks and watcher triggers.
``get(key, default)``
~~~~~~~~~~~~~~~~~~~~~
@@ -150,6 +151,20 @@ Deletes *all* asset state store keys for the asset.
# Using context
context["asset_state_store"][my_asset].clear()
+``aget``, ``aset``, ``adelete``, ``aclear``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Async counterparts of ``get``, ``set``, ``delete``, and ``clear``. They take
the same arguments and behave identically to their synchronous siblings —
``aset`` has no ``retention`` parameter either — but ``await`` the round-trip
to the API server instead of blocking the event loop, so a coroutine can read
and advance asset state without stalling other concurrent work.
+
+.. code-block:: python
+
+ watermark = await context["asset_state_store"][my_asset].aget("watermark",
default="initial_watermark")
+ await context["asset_state_store"][my_asset].aset("watermark",
"2024-06-01T00:00:00Z")
+ await context["asset_state_store"][my_asset].adelete("watermark")
+ await context["asset_state_store"][my_asset].aclear()
+
+Calling the synchronous ``get``/``set``/``delete``/``clear`` from inside an
``async`` task blocks the event loop and defeats the concurrency the coroutine
was written for. Use the ``a``-prefixed methods there instead.
+
Using ``asset_state_store`` inside a Watcher Trigger
-----------------------------------------------------
@@ -184,19 +199,21 @@ Unlike task-based access (where the asset is identified
by an inlet or outlet de
async def run(self) -> AsyncIterator[TriggerEvent]:
while True:
- last_seen = self.asset_state_store.get("last_seen_id",
default=0)
+ last_seen = await self.asset_state_store.aget("last_seen_id",
default=0)
new_id = self._poll_for_new_record(
source=self.source,
last_seen=last_seen,
)
if new_id is not None:
- self.asset_state_store.set("last_seen_id", new_id)
+ await self.asset_state_store.aset("last_seen_id", new_id)
yield TriggerEvent({"status": "success", "record_id":
new_id})
return
await asyncio.sleep(self.waiter_delay)
+``run()`` is a coroutine, and every trigger on a triggerer shares one event
loop, so use the async accessors there. The synchronous methods also work, but
they hold the loop for the whole round-trip to the API server.
+
The corresponding :class:`~airflow.sdk.definitions.asset.AssetWatcher` wires
the trigger to the asset:
.. code-block:: python
@@ -217,7 +234,7 @@ The corresponding
:class:`~airflow.sdk.definitions.asset.AssetWatcher` wires the
...
-``self.asset_state_store`` behaves identically to the per-asset accessor
described in the task sections above: ``get``, ``set``, ``delete``, and
``clear`` are all available. Values written by the trigger are visible to any
task that declares ``my_asset`` as an inlet or outlet, and vice versa.
+``self.asset_state_store`` behaves identically to the per-asset accessor
described in the task sections above: ``get``, ``set``, ``delete``, ``clear``
and their async counterparts ``aget``, ``aset``, ``adelete``, ``aclear`` are
all available. Values written by the trigger are visible to any task that
declares ``my_asset`` as an inlet or outlet, and vice versa.
.. note::
diff --git a/task-sdk/src/airflow/sdk/execution_time/context.py
b/task-sdk/src/airflow/sdk/execution_time/context.py
index e9deba57d67..58c58e5945e 100644
--- a/task-sdk/src/airflow/sdk/execution_time/context.py
+++ b/task-sdk/src/airflow/sdk/execution_time/context.py
@@ -72,6 +72,7 @@ if TYPE_CHECKING:
OKResponse,
PrevSuccessfulDagRunResponse,
ReceiveMsgType,
+ ToSupervisor,
VariableResult,
)
from airflow.sdk.state import BaseStoreBackend
@@ -694,21 +695,31 @@ class AssetStateStoreAccessor:
def get(self, key: str, default: JsonValue = None) -> JsonValue:
"""Return the stored value, or ``default`` if the key does not
exist."""
- from airflow.sdk.execution_time.comms import (
- AssetStateStoreResult,
- ErrorResponse,
- GetAssetStateStoreByName,
- GetAssetStateStoreByUri,
- ToSupervisor,
- )
from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
+ resp = SUPERVISOR_COMMS.send(self._build_get_message(key))
+ return self._extract_get_response(resp, key, default)
+
+ async def aget(self, key: str, default: JsonValue = None) -> JsonValue:
+ """Async version of `get` that awaits instead of blocking the event
loop."""
+ from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
+
+ resp = await SUPERVISOR_COMMS.asend(self._build_get_message(key))
+ return self._extract_get_response(resp, key, default)
+
+ def _build_get_message(self, key: str) -> ToSupervisor:
+ from airflow.sdk.execution_time.comms import GetAssetStateStoreByName,
GetAssetStateStoreByUri
+
msg: ToSupervisor
if self._name:
msg = GetAssetStateStoreByName(name=self._name, key=key)
elif self._uri:
msg = GetAssetStateStoreByUri(uri=self._uri, key=key)
- resp = SUPERVISOR_COMMS.send(msg)
+ return msg
+
+ def _extract_get_response(self, resp: Any, key: str, default: JsonValue)
-> JsonValue:
+ from airflow.sdk.execution_time.comms import AssetStateStoreResult,
ErrorResponse
+
if isinstance(resp, ErrorResponse) and resp.error !=
ErrorType.ASSET_STORE_NOT_FOUND:
raise AirflowRuntimeError(resp)
if isinstance(resp, AssetStateStoreResult):
@@ -730,13 +741,19 @@ class AssetStateStoreAccessor:
def set(self, key: str, value: JsonValue) -> None:
"""Write or overwrite the value for the given key. ``value`` must not
be ``None``."""
- from airflow.sdk.execution_time.comms import (
- SetAssetStateStoreByName,
- SetAssetStateStoreByUri,
- ToSupervisor,
- )
from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
+ SUPERVISOR_COMMS.send(self._build_set_message(key, value))
+
+ async def aset(self, key: str, value: JsonValue) -> None:
+ """Async version of `set` that awaits instead of blocking the event
loop."""
+ from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
+
+ await SUPERVISOR_COMMS.asend(self._build_set_message(key, value))
+
+ def _build_set_message(self, key: str, value: JsonValue) -> ToSupervisor:
+ from airflow.sdk.execution_time.comms import SetAssetStateStoreByName,
SetAssetStateStoreByUri
+
if value is None:
raise ValueError("Cannot set value as None")
@@ -765,49 +782,66 @@ class AssetStateStoreAccessor:
msg = SetAssetStateStoreByName(name=self._name, key=key,
value=stored)
elif self._uri:
msg = SetAssetStateStoreByUri(uri=self._uri, key=key, value=stored)
- SUPERVISOR_COMMS.send(msg)
+ return msg
def delete(self, key: str) -> None:
"""Delete a single key. No-op if the key does not exist."""
- from airflow.sdk._shared.state import AssetScope
- from airflow.sdk.execution_time.comms import (
- DeleteAssetStateStoreByName,
- DeleteAssetStateStoreByUri,
- ToSupervisor,
- )
from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
- msg: ToSupervisor
- if self._name:
- msg = DeleteAssetStateStoreByName(name=self._name, key=key)
- elif self._uri:
- msg = DeleteAssetStateStoreByUri(uri=self._uri, key=key)
# DB ref first: if backend cleanup fails after this, the ref is gone
and
# deterministic keys are recoverable on next set().
- SUPERVISOR_COMMS.send(msg)
+ SUPERVISOR_COMMS.send(self._build_delete_message(key))
backend = _get_worker_state_store_backend()
if backend is not None:
backend.delete(AssetScope(name=self._name, uri=self._uri), key)
+ async def adelete(self, key: str) -> None:
+ """Async version of `delete` that awaits instead of blocking the event
loop."""
+ from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
+
+ await SUPERVISOR_COMMS.asend(self._build_delete_message(key))
+ backend = _get_worker_state_store_backend()
+ if backend is not None:
+ await backend.adelete(AssetScope(name=self._name, uri=self._uri),
key)
+
+ def _build_delete_message(self, key: str) -> ToSupervisor:
+ from airflow.sdk.execution_time.comms import
DeleteAssetStateStoreByName, DeleteAssetStateStoreByUri
+
+ msg: ToSupervisor
+ if self._name:
+ msg = DeleteAssetStateStoreByName(name=self._name, key=key)
+ elif self._uri:
+ msg = DeleteAssetStateStoreByUri(uri=self._uri, key=key)
+ return msg
+
def clear(self) -> None:
"""Delete all state keys for this asset."""
- from airflow.sdk._shared.state import AssetScope
- from airflow.sdk.execution_time.comms import (
- ClearAssetStateStoreByName,
- ClearAssetStateStoreByUri,
- ToSupervisor,
- )
from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
+ # DB ref first, same ordering rationale as delete().
+ SUPERVISOR_COMMS.send(self._build_clear_message())
+ backend = _get_worker_state_store_backend()
+ if backend is not None:
+ backend.clear(AssetScope(name=self._name, uri=self._uri))
+
+ async def aclear(self) -> None:
+ """Async version of `clear` that awaits instead of blocking the event
loop."""
+ from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
+
+ await SUPERVISOR_COMMS.asend(self._build_clear_message())
+ backend = _get_worker_state_store_backend()
+ if backend is not None:
+ await backend.aclear(AssetScope(name=self._name, uri=self._uri))
+
+ def _build_clear_message(self) -> ToSupervisor:
+ from airflow.sdk.execution_time.comms import
ClearAssetStateStoreByName, ClearAssetStateStoreByUri
+
msg: ToSupervisor
if self._name:
msg = ClearAssetStateStoreByName(name=self._name)
elif self._uri:
msg = ClearAssetStateStoreByUri(uri=self._uri)
- SUPERVISOR_COMMS.send(msg)
- backend = _get_worker_state_store_backend()
- if backend is not None:
- backend.clear(AssetScope(name=self._name, uri=self._uri))
+ return msg
class AssetStateStoreAccessors:
@@ -818,7 +852,8 @@ class AssetStateStoreAccessors:
accessor as: ``context['asset_state_store'][MY_ASSET].get('watermark')``.
For tasks with exactly one concrete inlet or outlet, the accessor methods
(``get``,
- ``set``, ``delete``, ``clear``) can be called directly without
subscripting.
+ ``set``, ``delete``, ``clear``, and their async counterparts ``aget``,
``aset``,
+ ``adelete``, ``aclear``) can be called directly without subscripting.
"""
def __init__(self, inlets: list, outlets: list | None = None) -> None:
@@ -871,18 +906,34 @@ class AssetStateStoreAccessors:
"""Return the stored value for the single-inlet or single-outlet task,
or ``default`` if not found."""
return self._single_accessor().get(key, default)
+ async def aget(self, key: str, default: JsonValue = None) -> JsonValue:
+ """Async version of `get` that awaits instead of blocking the event
loop."""
+ return await self._single_accessor().aget(key, default)
+
def set(self, key: str, value: JsonValue) -> None:
"""Write or overwrite the value for the single-inlet task."""
self._single_accessor().set(key, value)
+ async def aset(self, key: str, value: JsonValue) -> None:
+ """Async version of `set` that awaits instead of blocking the event
loop."""
+ await self._single_accessor().aset(key, value)
+
def delete(self, key: str) -> None:
"""Delete a single key for the single-inlet task."""
self._single_accessor().delete(key)
+ async def adelete(self, key: str) -> None:
+ """Async version of `delete` that awaits instead of blocking the event
loop."""
+ await self._single_accessor().adelete(key)
+
def clear(self) -> None:
"""Delete all state keys for the single-inlet task."""
self._single_accessor().clear()
+ async def aclear(self) -> None:
+ """Async version of `clear` that awaits instead of blocking the event
loop."""
+ await self._single_accessor().aclear()
+
def __repr__(self) -> str:
parts = [f"name={k!r}" for k in self._by_name] + [f"uri={k!r}" for k
in self._by_uri]
return f"<AssetStateStoreAccessors [{', '.join(parts)}]>"
@@ -942,7 +993,6 @@ class _AssetRefResolutionMixin:
ErrorResponse,
GetAssetByName,
GetAssetByUri,
- ToSupervisor,
)
from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
@@ -1134,7 +1184,6 @@ class InletEventsAccessor(Sequence["AssetEventResult"]):
ErrorResponse,
GetAssetEventByAsset,
GetAssetEventByAssetAlias,
- ToSupervisor,
)
from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
diff --git a/task-sdk/tests/task_sdk/execution_time/test_context.py
b/task-sdk/tests/task_sdk/execution_time/test_context.py
index ba1ebb3694f..23e132432b2 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_context.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_context.py
@@ -27,7 +27,7 @@ import pytest
from pydantic import ValidationError
from airflow.sdk import BaseOperator, get_current_context, timezone
-from airflow.sdk._shared.state import TaskScope
+from airflow.sdk._shared.state import AssetScope, TaskScope
from airflow.sdk.api.datamodels._generated import (
AssetEventResponse,
AssetResponse,
@@ -1696,6 +1696,163 @@ class TestAssetStateStoreAccessor:
assert "max_value_storage_bytes" in
mock_log.warning.call_args[0][0]
mock_supervisor_comms.send.assert_called_once()
+ @pytest.mark.parametrize("lookup_by", ["name", "uri"])
+ @pytest.mark.asyncio
+ async def test_aget_returns_value(self, mock_supervisor_comms, lookup_by):
+ """aget awaits asend and returns the stored value, without touching
sync send."""
+ mock_supervisor_comms.asend.return_value =
AssetStateStoreResult(value="2026-04-30T00:00:00Z")
+ if lookup_by == "name":
+ accessor = AssetStateStoreAccessor(name=self.ASSET_NAME)
+ expected_message = GetAssetStateStoreByName(name=self.ASSET_NAME,
key="watermark")
+ else:
+ accessor = AssetStateStoreAccessor(uri=self.ASSET_URI)
+ expected_message = GetAssetStateStoreByUri(uri=self.ASSET_URI,
key="watermark")
+
+ result = await accessor.aget("watermark")
+
+ assert result == "2026-04-30T00:00:00Z"
+ mock_supervisor_comms.asend.assert_called_once_with(expected_message)
+ mock_supervisor_comms.send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_aget_returns_default_when_key_missing(self,
mock_supervisor_comms):
+ mock_supervisor_comms.asend.return_value = ErrorResponse(
+ error=ErrorType.ASSET_STORE_NOT_FOUND, detail={"key": "watermark"}
+ )
+
+ result = await AssetStateStoreAccessor(name=self.ASSET_NAME).aget(
+ "watermark", default="2026-01-01T00:00:00+00:00"
+ )
+
+ assert result == "2026-01-01T00:00:00+00:00"
+
+ @pytest.mark.asyncio
+ async def test_aget_raises_on_error(self, mock_supervisor_comms):
+ mock_supervisor_comms.asend.return_value = ErrorResponse(
+ error=ErrorType.GENERIC_ERROR, detail={"message": "server error"}
+ )
+
+ with pytest.raises(AirflowRuntimeError):
+ await
AssetStateStoreAccessor(name=self.ASSET_NAME).aget("some_key")
+
+ @pytest.mark.asyncio
+ async def test_aget_with_custom_backend_removes_decoration_marker(self,
mock_supervisor_comms):
+ """aget unwraps the external Store marker and resolves the ref via the
backend."""
+ mock_supervisor_comms.asend.return_value = AssetStateStoreResult(
+ value=_wrap_external_ref("s3://bucket/assets/orders/watermark")
+ )
+
+ backend = MagicMock(spec=BaseStoreBackend)
+ backend.deserialize_asset_state_store_from_ref.return_value =
"2026-05-01"
+
+ with patch(
+
"airflow.sdk.execution_time.context._get_worker_state_store_backend",
return_value=backend
+ ):
+ result = await
AssetStateStoreAccessor(name=self.ASSET_NAME).aget("watermark")
+
+ assert result == "2026-05-01"
+ backend.deserialize_asset_state_store_from_ref.assert_called_once_with(
+ "s3://bucket/assets/orders/watermark"
+ )
+
+ @pytest.mark.parametrize("lookup_by", ["name", "uri"])
+ @pytest.mark.asyncio
+ async def test_aset_operation(self, mock_supervisor_comms, lookup_by):
+ mock_supervisor_comms.asend.return_value = OKResponse(ok=True)
+ if lookup_by == "name":
+ accessor = AssetStateStoreAccessor(name=self.ASSET_NAME)
+ expected_message = SetAssetStateStoreByName(
+ name=self.ASSET_NAME, key="watermark",
value="2026-04-30T00:00:00Z"
+ )
+ else:
+ accessor = AssetStateStoreAccessor(uri=self.ASSET_URI)
+ expected_message = SetAssetStateStoreByUri(
+ uri=self.ASSET_URI, key="watermark",
value="2026-04-30T00:00:00Z"
+ )
+
+ await accessor.aset("watermark", "2026-04-30T00:00:00Z")
+
+ mock_supervisor_comms.asend.assert_called_once_with(expected_message)
+ mock_supervisor_comms.send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_aset_none_raises(self, mock_supervisor_comms):
+ with pytest.raises(ValueError, match="Cannot set value as None"):
+ await
AssetStateStoreAccessor(name=self.ASSET_NAME).aset("watermark", None)
+
+ mock_supervisor_comms.asend.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_aset_with_custom_backend_decorates_value_with_marker(self,
mock_supervisor_comms):
+ """aset wraps the custom backend ref in the external Store marker
before sending."""
+ mock_supervisor_comms.asend.return_value = OKResponse(ok=True)
+
+ backend = MagicMock(spec=BaseStoreBackend)
+ backend.serialize_asset_state_store_to_ref.return_value =
"s3://bucket/assets/orders/watermark"
+
+ with patch(
+
"airflow.sdk.execution_time.context._get_worker_state_store_backend",
return_value=backend
+ ):
+ await
AssetStateStoreAccessor(name=self.ASSET_NAME).aset("watermark", "2026-05-01")
+
+ mock_supervisor_comms.asend.assert_called_once_with(
+ SetAssetStateStoreByName(
+ name=self.ASSET_NAME,
+ key="watermark",
+
value=_wrap_external_ref("s3://bucket/assets/orders/watermark"),
+ )
+ )
+
+ @pytest.mark.asyncio
+ async def test_adelete_awaits_asend(self, mock_supervisor_comms):
+ """adelete awaits asend without touching sync send."""
+ mock_supervisor_comms.asend.return_value = OKResponse(ok=True)
+
+ await
AssetStateStoreAccessor(name=self.ASSET_NAME).adelete("watermark")
+
+ mock_supervisor_comms.asend.assert_called_once_with(
+ DeleteAssetStateStoreByName(name=self.ASSET_NAME, key="watermark")
+ )
+ mock_supervisor_comms.send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_aclear_awaits_asend(self, mock_supervisor_comms):
+ """aclear awaits asend without touching sync send."""
+ mock_supervisor_comms.asend.return_value = OKResponse(ok=True)
+
+ await AssetStateStoreAccessor(uri=self.ASSET_URI).aclear()
+
+
mock_supervisor_comms.asend.assert_called_once_with(ClearAssetStateStoreByUri(uri=self.ASSET_URI))
+ mock_supervisor_comms.send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_adelete_purges_via_async_backend(self,
mock_supervisor_comms):
+ """adelete awaits the async backend instead of blocking on the sync
delete."""
+ mock_supervisor_comms.asend.return_value = OKResponse(ok=True)
+ backend = MagicMock(spec=BaseStoreBackend)
+
+ with patch(
+
"airflow.sdk.execution_time.context._get_worker_state_store_backend",
return_value=backend
+ ):
+ await
AssetStateStoreAccessor(name=self.ASSET_NAME).adelete("watermark")
+
+
backend.adelete.assert_awaited_once_with(AssetScope(name=self.ASSET_NAME,
uri=None), "watermark")
+ backend.delete.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_aclear_purges_via_async_backend(self,
mock_supervisor_comms):
+ """aclear awaits the async backend instead of blocking on the sync
clear."""
+ mock_supervisor_comms.asend.return_value = OKResponse(ok=True)
+ backend = MagicMock(spec=BaseStoreBackend)
+
+ with patch(
+
"airflow.sdk.execution_time.context._get_worker_state_store_backend",
return_value=backend
+ ):
+ await AssetStateStoreAccessor(name=self.ASSET_NAME).aclear()
+
+
backend.aclear.assert_awaited_once_with(AssetScope(name=self.ASSET_NAME,
uri=None))
+ backend.clear.assert_not_called()
+
class TestAssetStateStoreAccessors:
ASSET_NAME = "my_asset"
@@ -1859,6 +2016,43 @@ class TestAssetStateStoreAccessors:
assert accessors._total == 0
mock_supervisor_comms.send.assert_not_called()
+ @pytest.mark.parametrize(
+ ("method_name", "expected_message"),
+ [
+ ("aget", GetAssetStateStoreByName(name=ASSET_NAME,
key="watermark")),
+ ("aset", SetAssetStateStoreByName(name=ASSET_NAME,
key="watermark", value="2026-05-01")),
+ ("adelete", DeleteAssetStateStoreByName(name=ASSET_NAME,
key="watermark")),
+ ("aclear", ClearAssetStateStoreByName(name=ASSET_NAME)),
+ ],
+ )
+ @pytest.mark.asyncio
+ async def test_single_inlet_async_shorthand(self, mock_supervisor_comms,
method_name, expected_message):
+ asset = Asset(name=self.ASSET_NAME, uri=f"s3://{self.ASSET_NAME}")
+ mock_supervisor_comms.asend.return_value = (
+ AssetStateStoreResult(value="v5") if method_name == "aget" else
OKResponse(ok=True)
+ )
+ accessors = AssetStateStoreAccessors([asset])
+
+ if method_name == "aget":
+ result = await accessors.aget("watermark")
+ assert result == "v5"
+ elif method_name == "aset":
+ await accessors.aset("watermark", "2026-05-01")
+ elif method_name == "adelete":
+ await accessors.adelete("watermark")
+ else:
+ await accessors.aclear()
+
+ mock_supervisor_comms.asend.assert_called_once_with(expected_message)
+
+ @pytest.mark.asyncio
+ async def test_double_reference_raises_for_async_accessor(self):
+ a1 = Asset(name="asset_one", uri="s3://one")
+ a2 = Asset(name="asset_two", uri="s3://two")
+
+ with pytest.raises(ValueError, match="2 concrete inlets and outlets"):
+ await AssetStateStoreAccessors([a1, a2]).aget("watermark")
+
class InMemoryStoreBackend(BaseStoreBackend):
"""Simple in-memory test backend."""
@@ -2043,3 +2237,32 @@ class TestAssetStateStoreAccessorWithCustomBackend:
assert "watermark" not in backend._actual_key_value_store
assert "file_count" not in backend._actual_key_value_store
mock_supervisor_comms.send.assert_any_call(ClearAssetStateStoreByName(name=self.ASSET_NAME))
+
+ @pytest.mark.asyncio
+ async def test_aset_sends_reference_not_value(self, mock_supervisor_comms,
backend):
+ """aset() stores actual value in backend and sends mem:// reference
via comms."""
+ mock_supervisor_comms.asend.return_value = OKResponse(ok=True)
+
+ await AssetStateStoreAccessor(name=self.ASSET_NAME).aset("watermark",
"2026-05-01")
+
+ expected_ref = f"mem://{self.ASSET_NAME}/watermark"
+ mock_supervisor_comms.asend.assert_called_once_with(
+ SetAssetStateStoreByName(
+ name=self.ASSET_NAME,
+ key="watermark",
+ value=_wrap_external_ref(expected_ref),
+ )
+ )
+ assert backend._actual_key_value_store["watermark"] == "2026-05-01"
+ assert backend.reference["watermark"] == expected_ref
+
+ @pytest.mark.asyncio
+ async def test_aget_resolves_reference_to_actual_value(self,
mock_supervisor_comms, backend):
+ """aget() fetches mem:// reference from DB, resolves it to actual
value via backend."""
+ ref = _wrap_external_ref(f"mem://{self.ASSET_NAME}/watermark")
+ backend._actual_key_value_store["watermark"] = "2026-05-01"
+ mock_supervisor_comms.asend.return_value =
AssetStateStoreResult(value=ref)
+
+ result = await
AssetStateStoreAccessor(name=self.ASSET_NAME).aget("watermark")
+
+ assert result == "2026-05-01"