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

uranusjr 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 4495e92002c Decode state store vals through backend; treat null as 
missing (#76009)
4495e92002c is described below

commit 4495e92002c2547f6bf10f494cb5a7b1895c0da6
Author: Tzu-ping Chung <[email protected]>
AuthorDate: Wed Nov 4 11:06:03 2026 +0800

    Decode state store vals through backend; treat null as missing (#76009)
---
 .../task-and-asset-state-store.rst                 |  2 +
 .../execution_api/routes/asset_state_store.py      | 10 ++---
 .../execution_api/routes/task_state_store.py       |  5 +--
 .../versions/head/test_asset_state_store.py        | 19 ++++++++-
 .../versions/head/test_task_state_store.py         | 22 +++++++++++
 airflow-core/tests/unit/state/test_metastore.py    |  7 ++++
 shared/state/src/airflow_shared/state/__init__.py  | 30 +++++++++++++--
 shared/state/tests/state/test_state.py             | 45 ++++++++++++++++++++++
 8 files changed, 127 insertions(+), 13 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 9b13da2abc1..0d88a3d28b7 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
@@ -128,6 +128,8 @@ Custom backends
 
 A custom backend must subclass :class:`~airflow.sdk.state.BaseStoreBackend` 
and implement its abstract methods: ``get``, ``set``, ``delete``, and ``clear`` 
for synchronous callers and the ``aget``, ``aset``, ``adelete``, and ``aclear`` 
async equivalents. Refer to :class:`~airflow.sdk.state.BaseStoreBackend` for 
the full API.
 
+Values are passed to :meth:`~airflow.sdk.state.BaseStoreBackend.set` and 
returned from :meth:`~airflow.sdk.state.BaseStoreBackend.get` as JSON encoded 
strings, and are never the JSON ``null``. The Execution API reads values 
through :meth:`~airflow.sdk.state.BaseStoreBackend.get_value` 
(:meth:`~airflow.sdk.state.BaseStoreBackend.aget_value` is the async 
equivalent); a backend that stores values natively can override them to skip 
decoding on reads.
+
 Each method receives a ``scope`` argument that is either a 
:class:`~airflow.sdk.state.TaskScope` or an 
:class:`~airflow.sdk.state.AssetScope`. The union of the two is exported as 
``airflow.sdk.state.StoreScope`` for type annotations. Use ``isinstance`` to 
dispatch:
 
 .. code-block:: python
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_state_store.py
 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_state_store.py
index f5d387b495a..466b5f22b74 100644
--- 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_state_store.py
+++ 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_state_store.py
@@ -129,13 +129,12 @@ def get_asset_state_store_by_name(
 ) -> AssetStateStoreResponse:
     """Get an asset state store value by asset name."""
     asset_id = _resolve_asset_id_by_name(name, session)
-    value = get_state_backend().get(AssetScope(asset_id=asset_id), key, 
session=session)
-    if value is None:
+    if (value := get_state_backend().get_value(AssetScope(asset_id=asset_id), 
key, session=session)) is None:
         raise HTTPException(
             status_code=status.HTTP_404_NOT_FOUND,
             detail={"reason": "not_found", "message": f"Asset state store key 
{key!r} not found"},
         )
-    return AssetStateStoreResponse(value=json.loads(value))
+    return AssetStateStoreResponse(value=value)
 
 
 def _put_asset_state_store(
@@ -221,13 +220,12 @@ def get_asset_state_store_by_uri(
 ) -> AssetStateStoreResponse:
     """Get an asset state store value by asset URI."""
     asset_id = _resolve_asset_id_by_uri(uri, session)
-    value = get_state_backend().get(AssetScope(asset_id=asset_id), key, 
session=session)
-    if value is None:
+    if (value := get_state_backend().get_value(AssetScope(asset_id=asset_id), 
key, session=session)) is None:
         raise HTTPException(
             status_code=status.HTTP_404_NOT_FOUND,
             detail={"reason": "not_found", "message": f"Asset state store key 
{key!r} not found"},
         )
-    return AssetStateStoreResponse(value=json.loads(value))
+    return AssetStateStoreResponse(value=value)
 
 
 @router.put("/by-uri/value", status_code=status.HTTP_204_NO_CONTENT)
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_state_store.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_state_store.py
index 94b4ec01391..4289af2be30 100644
--- 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_state_store.py
+++ 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_state_store.py
@@ -72,8 +72,7 @@ def get_task_state_store(
 ) -> TaskStateStoreResponse:
     """Get value for a task state store key."""
     scope = _get_task_scope_for_ti(task_instance_id, session)
-    value = get_state_backend().get(scope, key, session=session)
-    if value is None:
+    if (value := get_state_backend().get_value(scope, key, session=session)) 
is None:
         raise HTTPException(
             status_code=status.HTTP_404_NOT_FOUND,
             detail={
@@ -81,7 +80,7 @@ def get_task_state_store(
                 "message": f"Task state store key {key!r} not found",
             },
         )
-    return TaskStateStoreResponse(value=json.loads(value))
+    return TaskStateStoreResponse(value=value)
 
 
 @router.put("/{task_instance_id}/{key:path}", 
status_code=status.HTTP_204_NO_CONTENT)
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_state_store.py
 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_state_store.py
index 97ed0fe1562..d24c6e50d61 100644
--- 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_state_store.py
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_state_store.py
@@ -37,6 +37,7 @@ from tests_common.test_utils.mock_operators import 
MockOperator
 if TYPE_CHECKING:
     from fastapi import FastAPI
     from fastapi.testclient import TestClient
+    from pydantic import JsonValue
     from sqlalchemy.orm import Session
 
     from airflow.models.taskinstance import TaskInstance
@@ -48,7 +49,7 @@ pytestmark = pytest.mark.db_test
 _BY_NAME_VALUE = "/execution/store/asset/by-name/value"
 
 
-def _create_asset_store_row(asset_id: int, key: str, value: str) -> None:
+def _create_asset_store_row(asset_id: int, key: str, value: JsonValue) -> None:
     """Insert an AssetStateStoreModel row directly in the database."""
     with create_session() as s:
         s.add(AssetStateStoreModel(asset_id=asset_id, key=key, 
value=json.dumps(value)))
@@ -86,6 +87,14 @@ def inactive_asset(session: Session) -> AssetModel:
 
 
 class TestGetAssetStateByName:
+    def test_get_stored_null_returns_404(self, client: TestClient, asset: 
AssetModel):
+        _create_asset_store_row(asset.id, "watermark", None)
+
+        response = client.get(_BY_NAME_VALUE, params={"name": asset.name, 
"key": "watermark"})
+
+        assert response.status_code == 404
+        assert response.json()["detail"]["reason"] == "not_found"
+
     def test_get_returns_value(self, client: TestClient, asset: AssetModel):
         _create_asset_store_row(asset.id, "watermark", "2026-04-29")
 
@@ -320,6 +329,14 @@ class TestClearAssetStateByName:
 
 
 class TestGetAssetStateByUri:
+    def test_get_stored_null_returns_404(self, client: TestClient, asset: 
AssetModel):
+        _create_asset_store_row(asset.id, "watermark", None)
+
+        response = client.get(_BY_URI_VALUE, params={"uri": asset.uri, "key": 
"watermark"})
+
+        assert response.status_code == 404
+        assert response.json()["detail"]["reason"] == "not_found"
+
     def test_get_returns_value(self, client: TestClient, asset: AssetModel):
         _create_asset_store_row(asset.id, "watermark", "2026-04-29")
 
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_state_store.py
 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_state_store.py
index 042ae4ee653..f621c2b78fe 100644
--- 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_state_store.py
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_state_store.py
@@ -79,6 +79,28 @@ def 
test_task_uuid_isolates_state_in_different_regions(client, create_task_insta
 
 
 class TestGetTaskState:
+    def test_get_stored_null_returns_404(self, client: TestClient, 
create_task_instance: CreateTaskInstance):
+        ti = create_task_instance()
+        with create_session() as session:
+            session.add(
+                TaskStateStoreModel(
+                    dag_run_id=ti.dag_run.id,
+                    dag_id=ti.dag_id,
+                    run_id=ti.run_id,
+                    task_id=ti.task_id,
+                    region_id=ti.region_id,
+                    region_index=ti.region_index,
+                    key="job_id",
+                    value="null",
+                    updated_at=timezone.utcnow(),
+                )
+            )
+
+        response = client.get(_api_url(ti.id, "job_id"))
+
+        assert response.status_code == 404
+        assert response.json()["detail"]["reason"] == "not_found"
+
     def test_get_returns_value(self, client: TestClient, create_task_instance: 
CreateTaskInstance):
         ti = create_task_instance()
         client.put(_api_url(ti.id, "job_id"), json={"value": "spark_001"})
diff --git a/airflow-core/tests/unit/state/test_metastore.py 
b/airflow-core/tests/unit/state/test_metastore.py
index d353949374f..de9335dc83b 100644
--- a/airflow-core/tests/unit/state/test_metastore.py
+++ b/airflow-core/tests/unit/state/test_metastore.py
@@ -400,6 +400,13 @@ class TestMetastoreBackendTaskScope:
 
 
 class TestMetastoreBackendAssetScope:
+    def test_get_value_without_session(self, backend: MetastoreBackend, 
asset_committed: AssetModel):
+        scope = AssetScope(asset_id=asset_committed.id)
+        backend.set(scope, "watermark", '"2026-04-29"')
+
+        assert backend.get_value(scope, "watermark") == "2026-04-29"
+        assert backend.get_value(scope, "missing") is None
+
     def test_get_returns_none_for_missing_key(
         self, session: Session, backend: MetastoreBackend, asset: AssetModel
     ):
diff --git a/shared/state/src/airflow_shared/state/__init__.py 
b/shared/state/src/airflow_shared/state/__init__.py
index 5c0ed914b59..555d109f1e8 100644
--- a/shared/state/src/airflow_shared/state/__init__.py
+++ b/shared/state/src/airflow_shared/state/__init__.py
@@ -167,8 +167,9 @@ class BaseStoreBackend(ABC):
         """
         Return the stored JSON encoded value string, or None if the key does 
not exist.
 
-        Must handle both ``TaskScope`` and ``AssetScope``. The execution API 
calls
-        ``json.loads`` on the returned string from here, so it must be a valid 
JSON document.
+        Must handle both ``TaskScope`` and ``AssetScope``. :meth:`get_value` 
decodes the
+        returned string with ``json.loads``, so it must be a valid JSON 
document. It is
+        never the JSON ``null``, since :meth:`set` never receives one.
         """
 
     @abstractmethod
@@ -186,7 +187,8 @@ class BaseStoreBackend(ABC):
 
         Must handle both ``TaskScope`` and ``AssetScope``. ``value`` is always 
a
         JSON encoded string (the execution API calls ``json.dumps`` before 
passing it
-        here); store it verbatim so ``get`` can return it unchanged.
+        here); store it verbatim so ``get`` can return it unchanged. It is 
never the
+        JSON ``null``; callers reject a null value before it reaches the 
backend.
 
         ``expires_at`` is an absolute UTC datetime after which the row may be 
deleted.
         Pass ``None`` (default) for a key that should never expire — stored as 
``NULL``,
@@ -269,6 +271,28 @@ class BaseStoreBackend(ABC):
         If ``None``, implementations manage their own async session internally.
         """
 
+    def get_value(self, scope: StoreScope, key: str, *, session: Session | 
None = None) -> JsonValue:
+        """
+        Return the decoded value for the given key, or None if the key does 
not exist.
+
+        The default implementation decodes what :meth:`get` returns. A stored 
JSON ``null``
+        breaks the :meth:`set` contract, so it is treated as a missing key. A 
backend that
+        stores values natively, rather than as JSON strings, can override this 
to skip
+        decoding on reads.
+        """
+        kwargs = {"session": session} if session is not None else {}
+        if (value := self.get(scope, key, **kwargs)) is None:
+            return None
+        return json.loads(value)
+
+    async def aget_value(
+        self, scope: StoreScope, key: str, *, session: AsyncSession | None = 
None
+    ) -> JsonValue:
+        """Async variant of :meth:`get_value`."""
+        if (value := await self.aget(scope, key, session=session)) is None:
+            return None
+        return json.loads(value)
+
     def cleanup(self) -> None:
         """
         Remove expired and orphaned state records.
diff --git a/shared/state/tests/state/test_state.py 
b/shared/state/tests/state/test_state.py
index 578ae302212..f5782988eda 100644
--- a/shared/state/tests/state/test_state.py
+++ b/shared/state/tests/state/test_state.py
@@ -208,3 +208,48 @@ class TestBaseStoreBackend:
             
b.deserialize_asset_state_store_from_ref("s3://bucket/assets/my_asset/watermark")
             == "resolved:s3://bucket/assets/my_asset/watermark"
         )
+
+
+class _StoredValueBackend(BaseStoreBackend):
+    """Backend whose ``get`` and ``aget`` return one fixed stored string."""
+
+    def __init__(self, stored: str | None):
+        self.stored = stored
+
+    def get(self, scope, key, *, session=None):
+        return self.stored
+
+    def set(self, scope, key, value, *, expires_at=None, session=None): ...
+    def delete(self, scope, key, *, session=None): ...
+    def clear(self, scope, *, all_map_indices=False, session=None): ...
+
+    async def aget(self, scope, key, *, session=None):
+        return self.stored
+
+    async def aset(self, scope, key, value, *, expires_at=None, session=None): 
...
+    async def adelete(self, scope, key, *, session=None): ...
+    async def aclear(self, scope, *, all_map_indices=False, session=None): ...
+
+
+_GET_VALUE_CASES = [
+    pytest.param(None, None, id="missing"),
+    pytest.param("null", None, id="stored-null"),
+    pytest.param('"x"', "x", id="string"),
+    pytest.param("0", 0, id="falsy-number"),
+    pytest.param('{"a": [1]}', {"a": [1]}, id="object"),
+]
+
+
+class TestBaseStoreBackendGetValue:
+    @pytest.mark.parametrize(("stored", "expected"), _GET_VALUE_CASES)
+    def test_get_value(self, stored, expected):
+        backend = _StoredValueBackend(stored)
+
+        assert backend.get_value(TaskScope("dag", "run", "task"), "key") == 
expected
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(("stored", "expected"), _GET_VALUE_CASES)
+    async def test_aget_value(self, stored, expected):
+        backend = _StoredValueBackend(stored)
+
+        assert await backend.aget_value(AssetScope(name="asset"), "key") == 
expected

Reply via email to