This is an automated email from the ASF dual-hosted git repository.
amoghrajesh 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 ffb1b8a6d22 AIP-103: Add Execution API endpoints for task and asset
states (#66073)
ffb1b8a6d22 is described below
commit ffb1b8a6d22b855da6f6a785c22125369732fd37
Author: Amogh Desai <[email protected]>
AuthorDate: Mon May 4 10:05:04 2026 +0530
AIP-103: Add Execution API endpoints for task and asset states (#66073)
---
.../execution_api/datamodels/asset_state.py} | 34 ++-
.../execution_api/datamodels/task_state.py} | 34 ++-
.../api_fastapi/execution_api/routes/__init__.py | 4 +
.../execution_api/routes/asset_state.py | 123 +++++++++
.../api_fastapi/execution_api/routes/task_state.py | 128 ++++++++++
.../api_fastapi/execution_api/versions/__init__.py | 3 +-
.../execution_api/versions/v2026_04_17.py | 19 +-
.../0113_3_3_0_add_retry_policy_fields_to_ti.py | 2 +-
airflow-core/src/airflow/state/__init__.py | 16 ++
.../versions/head/test_asset_state.py | 183 ++++++++++++++
.../execution_api/versions/head/test_task_state.py | 274 +++++++++++++++++++++
.../src/airflow/sdk/api/datamodels/_generated.py | 44 ++++
12 files changed, 821 insertions(+), 43 deletions(-)
diff --git a/airflow-core/src/airflow/state/__init__.py
b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/asset_state.py
similarity index 56%
copy from airflow-core/src/airflow/state/__init__.py
copy to
airflow-core/src/airflow/api_fastapi/execution_api/datamodels/asset_state.py
index 89fa0801a06..ec773201c7e 100644
--- a/airflow-core/src/airflow/state/__init__.py
+++
b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/asset_state.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,24 +14,19 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+
from __future__ import annotations
-from airflow._shared.state import (
- AssetScope as AssetScope,
- BaseStateBackend as BaseStateBackend,
- StateScope as StateScope,
- TaskScope as TaskScope,
-)
-
-
-def resolve_state_backend() -> type[BaseStateBackend]:
- from airflow.configuration import conf
-
- clazz = conf.getimport("state_store", "backend")
- if clazz is None:
- raise ValueError("state_store.backend is not configured or resolved to
None.")
- if not issubclass(clazz, BaseStateBackend):
- raise TypeError(
- f"Your custom state backend `{clazz.__name__}` is not a subclass
of `BaseStateBackend`."
- )
- return clazz
+from airflow.api_fastapi.core_api.base import StrictBaseModel
+
+
+class AssetStateResponse(StrictBaseModel):
+ """Asset state value returned to a worker."""
+
+ value: str
+
+
+class AssetStatePutBody(StrictBaseModel):
+ """Request body for setting an asset state value."""
+
+ value: str
diff --git a/airflow-core/src/airflow/state/__init__.py
b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/task_state.py
similarity index 56%
copy from airflow-core/src/airflow/state/__init__.py
copy to
airflow-core/src/airflow/api_fastapi/execution_api/datamodels/task_state.py
index 89fa0801a06..3200f3177af 100644
--- a/airflow-core/src/airflow/state/__init__.py
+++
b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/task_state.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,24 +14,19 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+
from __future__ import annotations
-from airflow._shared.state import (
- AssetScope as AssetScope,
- BaseStateBackend as BaseStateBackend,
- StateScope as StateScope,
- TaskScope as TaskScope,
-)
-
-
-def resolve_state_backend() -> type[BaseStateBackend]:
- from airflow.configuration import conf
-
- clazz = conf.getimport("state_store", "backend")
- if clazz is None:
- raise ValueError("state_store.backend is not configured or resolved to
None.")
- if not issubclass(clazz, BaseStateBackend):
- raise TypeError(
- f"Your custom state backend `{clazz.__name__}` is not a subclass
of `BaseStateBackend`."
- )
- return clazz
+from airflow.api_fastapi.core_api.base import StrictBaseModel
+
+
+class TaskStateResponse(StrictBaseModel):
+ """Task state value returned to a worker."""
+
+ value: str
+
+
+class TaskStatePutBody(StrictBaseModel):
+ """Request body for setting a task state value."""
+
+ value: str
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/__init__.py
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/__init__.py
index a076592d647..06f07aee823 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/routes/__init__.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/__init__.py
@@ -21,6 +21,7 @@ from fastapi import APIRouter, Security
from airflow.api_fastapi.execution_api.routes import (
asset_events,
+ asset_state,
assets,
connections,
dag_runs,
@@ -29,6 +30,7 @@ from airflow.api_fastapi.execution_api.routes import (
hitl,
task_instances,
task_reschedules,
+ task_state,
variables,
xcoms,
)
@@ -52,5 +54,7 @@ authenticated_router.include_router(
authenticated_router.include_router(variables.router, prefix="/variables",
tags=["Variables"])
authenticated_router.include_router(xcoms.router, prefix="/xcoms",
tags=["XComs"])
authenticated_router.include_router(hitl.router, prefix="/hitlDetails",
tags=["Human in the Loop"])
+authenticated_router.include_router(task_state.router, prefix="/state/ti",
tags=["Task State"])
+authenticated_router.include_router(asset_state.router, prefix="/state/asset",
tags=["Asset State"])
execution_api_router.include_router(authenticated_router)
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_state.py
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_state.py
new file mode 100644
index 00000000000..ba00260b16b
--- /dev/null
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_state.py
@@ -0,0 +1,123 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Execution API routes for asset state.
+
+Asset state is keyed by asset *name* (not integer id) in the URL — asset names
+are unique, and callers (task SDK accessors) have the name from their Asset
+object without needing a DB lookup. The route resolves name → asset_id
+internally for the state backend scope.
+
+Per-task asset registration checks are intentionally not implemented here
+(deferred to AIP-93 — see TODO comment below).
+"""
+
+from __future__ import annotations
+
+from typing import Annotated
+
+from cadwyn import VersionedAPIRouter
+from fastapi import HTTPException, Query, status
+from sqlalchemy import select
+
+from airflow._shared.state import AssetScope
+from airflow.api_fastapi.common.db.common import SessionDep
+from airflow.api_fastapi.execution_api.datamodels.asset_state import (
+ AssetStatePutBody,
+ AssetStateResponse,
+)
+from airflow.api_fastapi.execution_api.security import ExecutionAPIRoute
+from airflow.models.asset import AssetModel
+from airflow.state import get_state_backend
+
+# TODO(AIP-103): enforce that the requesting task is registered with the asset
+# (via task_inlet_asset_reference or task_outlet_asset_reference) before
+# allowing reads/writes. Currently any task with a valid execution token can
+# access any asset's state — the same gap exists in /assets and /asset-events.
+# Proper fix is a unified asset-registration check across all asset routes,
+# not just here.
+router = VersionedAPIRouter(
+ route_class=ExecutionAPIRoute,
+ responses={
+ status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
+ status.HTTP_404_NOT_FOUND: {"description": "Not found"},
+ },
+)
+
+
+def _resolve_asset_id(name: str, session: SessionDep) -> int:
+ """Resolve asset name → integer asset_id, 404 if not found."""
+ asset_id = session.scalar(select(AssetModel.id).where(AssetModel.name ==
name, AssetModel.active.has()))
+ if asset_id is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail={"reason": "not_found", "message": f"Asset {name!r} not
found"},
+ )
+ return asset_id
+
+
[email protected]("/value")
+def get_asset_state(
+ name: Annotated[str, Query(min_length=1)],
+ key: Annotated[str, Query(min_length=1)],
+ session: SessionDep,
+) -> AssetStateResponse:
+ """Get an asset state value."""
+ asset_id = _resolve_asset_id(name, session)
+ 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
+ if value is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail={
+ "reason": "not_found",
+ "message": f"Asset state key {key!r} not found",
+ },
+ )
+ return AssetStateResponse(value=value)
+
+
[email protected]("/value", status_code=status.HTTP_204_NO_CONTENT)
+def set_asset_state(
+ name: Annotated[str, Query(min_length=1)],
+ key: Annotated[str, Query(min_length=1)],
+ body: AssetStatePutBody,
+ session: SessionDep,
+) -> None:
+ """Set an asset state value."""
+ asset_id = _resolve_asset_id(name, session)
+ 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
+
+
[email protected]("/value", status_code=status.HTTP_204_NO_CONTENT)
+def delete_asset_state(
+ name: Annotated[str, Query(min_length=1)],
+ key: Annotated[str, Query(min_length=1)],
+ session: SessionDep,
+) -> None:
+ """Delete a single asset state key."""
+ asset_id = _resolve_asset_id(name, session)
+ 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
+
+
[email protected]("/clear", status_code=status.HTTP_204_NO_CONTENT)
+def clear_asset_state(
+ name: Annotated[str, Query(min_length=1)],
+ session: SessionDep,
+) -> None:
+ """Delete all state keys for an asset."""
+ asset_id = _resolve_asset_id(name, session)
+ 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
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_state.py
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_state.py
new file mode 100644
index 00000000000..db24109969c
--- /dev/null
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_state.py
@@ -0,0 +1,128 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from typing import Annotated
+from uuid import UUID
+
+from cadwyn import VersionedAPIRouter
+from fastapi import HTTPException, Path, Query, Security, status
+from sqlalchemy.orm import Session
+
+from airflow._shared.state import TaskScope
+from airflow.api_fastapi.common.db.common import SessionDep
+from airflow.api_fastapi.execution_api.datamodels.task_state import (
+ TaskStatePutBody,
+ TaskStateResponse,
+)
+from airflow.api_fastapi.execution_api.security import ExecutionAPIRoute,
require_auth
+from airflow.models.taskinstance import TaskInstance as TI
+from airflow.state import get_state_backend
+
+router = VersionedAPIRouter(
+ route_class=ExecutionAPIRoute,
+ responses={
+ status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
+ status.HTTP_403_FORBIDDEN: {"description": "Access denied"},
+ status.HTTP_404_NOT_FOUND: {"description": "Not found"},
+ },
+ dependencies=[Security(require_auth, scopes=["ti:self"])],
+)
+
+
+def _get_task_scope_for_ti(task_instance_id: UUID, session: Session) ->
TaskScope:
+ ti = session.get(TI, task_instance_id)
+ if ti is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail={
+ "reason": "not_found",
+ "message": f"Task instance {task_instance_id} not found",
+ },
+ )
+ return TaskScope(dag_id=ti.dag_id, run_id=ti.run_id, task_id=ti.task_id,
map_index=ti.map_index)
+
+
[email protected]("/{task_instance_id}/{key}")
+def get_task_state(
+ task_instance_id: UUID,
+ key: Annotated[str, Path(min_length=1)],
+ session: SessionDep,
+) -> TaskStateResponse:
+ """Get value for a task state."""
+ scope = _get_task_scope_for_ti(task_instance_id, session)
+ 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
+ if value is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail={
+ "reason": "not_found",
+ "message": f"Task state key {key!r} not found",
+ },
+ )
+ return TaskStateResponse(value=value)
+
+
[email protected]("/{task_instance_id}/{key}",
status_code=status.HTTP_204_NO_CONTENT)
+def set_task_state(
+ task_instance_id: UUID,
+ key: Annotated[str, Path(min_length=1)],
+ body: TaskStatePutBody,
+ session: SessionDep,
+) -> None:
+ """Set a task state key, creating or updating the row."""
+ scope = _get_task_scope_for_ti(task_instance_id, session)
+ 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
+
+
[email protected]("/{task_instance_id}/{key}",
status_code=status.HTTP_204_NO_CONTENT)
+def delete_task_state(
+ task_instance_id: UUID,
+ key: Annotated[str, Path(min_length=1)],
+ session: SessionDep,
+) -> None:
+ """Delete a single task state key."""
+ scope = _get_task_scope_for_ti(task_instance_id, session)
+ 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
+
+
[email protected]("/{task_instance_id}", status_code=status.HTTP_204_NO_CONTENT)
+def clear_task_state(
+ task_instance_id: UUID,
+ session: SessionDep,
+ all_map_indices: Annotated[bool, Query()] = False,
+) -> None:
+ """
+ Delete all task state keys for this task instance.
+
+ By default, only keys for the requesting TI's exact ``map_index`` are
+ cleared — same isolation as DELETE endpoint above.
+
+ Pass ``?all_map_indices=true`` to wipe state for every mapped sibling of
+ the task in the same DAG run. This is intentionally fleet-wide: the
+ ``ti:self`` JWT authentication scope authenticates that the caller is
+ a legitimate member of the mapped task group, and grants it authority
+ to reset shared task state on behalf of the whole group.
+ The SDK only forwards this flag when the user calls
``task_state.clear(all_map_indices=True)``
+ explicitly, so the expanded scope is always an explicit opt-in by the task
author.
+
+ For non-mapped tasks (``map_index=-1``), there is only ever one index, so
+ ``?all_map_indices=true`` is functionally identical to the default and is
+ accepted without error.
+ """
+ scope = _get_task_scope_for_ti(task_instance_id, session)
+ 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
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
b/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
index 7ccaf3fdf15..dfa27f53ebd 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
@@ -40,7 +40,7 @@ from airflow.api_fastapi.execution_api.versions.v2026_04_06
import (
MovePreviousRunEndpoint,
RemoveUpstreamMapIndexesField,
)
-from airflow.api_fastapi.execution_api.versions.v2026_04_17 import
AddTeamNameField
+from airflow.api_fastapi.execution_api.versions.v2026_04_17 import
AddStateEndpoints, AddTeamNameField
from airflow.api_fastapi.execution_api.versions.v2026_06_16 import
AddRetryPolicyFields
bundle = VersionBundle(
@@ -49,6 +49,7 @@ bundle = VersionBundle(
Version(
"2026-04-17",
AddTeamNameField,
+ AddStateEndpoints,
),
Version(
"2026-04-06",
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_04_17.py
b/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_04_17.py
index e7cd9d331a5..c08454c4786 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_04_17.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_04_17.py
@@ -17,7 +17,7 @@
from __future__ import annotations
-from cadwyn import ResponseInfo, VersionChange,
convert_response_to_previous_version_for, schema
+from cadwyn import ResponseInfo, VersionChange,
convert_response_to_previous_version_for, endpoint, schema
from airflow.api_fastapi.execution_api.datamodels.taskinstance import DagRun,
TIRunContext
@@ -34,3 +34,20 @@ class AddTeamNameField(VersionChange):
"""Remove the ``team_name`` field from dag_run for older API
versions."""
if "dag_run" in response.body and isinstance(response.body["dag_run"],
dict):
response.body["dag_run"].pop("team_name", None)
+
+
+class AddStateEndpoints(VersionChange):
+ """Add task state and asset state CRUD endpoints."""
+
+ description = __doc__
+
+ instructions_to_migrate_to_previous_version = (
+ endpoint("/state/ti/{task_instance_id}/{key}", ["GET"]).didnt_exist,
+ endpoint("/state/ti/{task_instance_id}/{key}", ["PUT"]).didnt_exist,
+ endpoint("/state/ti/{task_instance_id}/{key}", ["DELETE"]).didnt_exist,
+ endpoint("/state/ti/{task_instance_id}", ["DELETE"]).didnt_exist,
+ endpoint("/state/asset/value", ["GET"]).didnt_exist,
+ endpoint("/state/asset/value", ["PUT"]).didnt_exist,
+ endpoint("/state/asset/value", ["DELETE"]).didnt_exist,
+ endpoint("/state/asset/clear", ["DELETE"]).didnt_exist,
+ )
diff --git
a/airflow-core/src/airflow/migrations/versions/0113_3_3_0_add_retry_policy_fields_to_ti.py
b/airflow-core/src/airflow/migrations/versions/0113_3_3_0_add_retry_policy_fields_to_ti.py
index 5fc8007e417..a390163b57d 100644
---
a/airflow-core/src/airflow/migrations/versions/0113_3_3_0_add_retry_policy_fields_to_ti.py
+++
b/airflow-core/src/airflow/migrations/versions/0113_3_3_0_add_retry_policy_fields_to_ti.py
@@ -30,7 +30,7 @@ On PostgreSQL and MySQL 8+, adding nullable columns without
defaults
is a metadata-only operation (no table rewrite).
Revision ID: b8f3e4a1d2c9
-Revises: 9fabad868fdb
+Revises: fde9ed84d07b
Create Date: 2026-04-16 12:00:00.000000
"""
diff --git a/airflow-core/src/airflow/state/__init__.py
b/airflow-core/src/airflow/state/__init__.py
index 89fa0801a06..109b9e9b37f 100644
--- a/airflow-core/src/airflow/state/__init__.py
+++ b/airflow-core/src/airflow/state/__init__.py
@@ -17,6 +17,8 @@
# under the License.
from __future__ import annotations
+import threading
+
from airflow._shared.state import (
AssetScope as AssetScope,
BaseStateBackend as BaseStateBackend,
@@ -36,3 +38,17 @@ def resolve_state_backend() -> type[BaseStateBackend]:
f"Your custom state backend `{clazz.__name__}` is not a subclass
of `BaseStateBackend`."
)
return clazz
+
+
+_backend_instance: BaseStateBackend | None = None
+_backend_lock = threading.Lock()
+
+
+def get_state_backend() -> BaseStateBackend:
+ """Return a cached instance of the configured state backend."""
+ global _backend_instance
+ if _backend_instance is None:
+ with _backend_lock:
+ if _backend_instance is None:
+ _backend_instance = resolve_state_backend()()
+ return _backend_instance
diff --git
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_state.py
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_state.py
new file mode 100644
index 00000000000..8bdf6ac8599
--- /dev/null
+++
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_state.py
@@ -0,0 +1,183 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from sqlalchemy import delete, select
+
+from airflow.models.asset import AssetActive, AssetModel
+from airflow.models.asset_state import AssetStateModel
+from airflow.utils.session import create_session
+
+if TYPE_CHECKING:
+ from fastapi.testclient import TestClient
+ from sqlalchemy.orm import Session
+
+pytestmark = pytest.mark.db_test
+
+
[email protected](autouse=True)
+def reset_state_tables():
+ with create_session() as session:
+ session.execute(delete(AssetStateModel))
+ session.execute(delete(AssetModel))
+
+
[email protected]
+def asset(session: Session) -> AssetModel:
+ asset = AssetModel(name="test_asset", uri="s3://bucket/test",
group="asset")
+ session.add(asset)
+ session.flush()
+ session.add(AssetActive.for_asset(asset))
+ session.commit()
+ return asset
+
+
[email protected]
+def inactive_asset(session: Session) -> AssetModel:
+ """An asset row with no asset_active entry — simulates a removed asset."""
+ asset = AssetModel(name="inactive_asset", uri="s3://bucket/inactive",
group="asset")
+ session.add(asset)
+ session.commit()
+ return asset
+
+
+_VALUE_URL = "/execution/state/asset/value"
+_CLEAR_URL = "/execution/state/asset/clear"
+
+
+class TestGetAssetState:
+ def test_get_returns_value(self, client: TestClient, asset: AssetModel):
+ client.put(_VALUE_URL, params={"name": asset.name, "key":
"watermark"}, json={"value": "2026-04-29"})
+
+ response = client.get(_VALUE_URL, params={"name": asset.name, "key":
"watermark"})
+
+ assert response.status_code == 200
+ assert response.json() == {"value": "2026-04-29"}
+
+ def test_get_missing_key_returns_404(self, client: TestClient, asset:
AssetModel):
+ response = client.get(_VALUE_URL, params={"name": asset.name, "key":
"never_set"})
+
+ assert response.status_code == 404
+ assert response.json()["detail"]["reason"] == "not_found"
+
+ def test_get_asset_name_with_slashes(self, client: TestClient, session):
+ slashed = AssetModel(name="team/sales/orders",
uri="s3://bucket/team/sales", group="asset")
+ session.add(slashed)
+ session.flush()
+ session.add(AssetActive.for_asset(slashed))
+ session.commit()
+
+ client.put(_VALUE_URL, params={"name": slashed.name, "key": "wm"},
json={"value": "x"})
+ response = client.get(_VALUE_URL, params={"name": slashed.name, "key":
"wm"})
+
+ assert response.status_code == 200
+ assert response.json() == {"value": "x"}
+
+
+class TestPutAssetState:
+ def test_put_creates_row(self, client: TestClient, asset: AssetModel):
+ response = client.put(
+ _VALUE_URL, params={"name": asset.name, "key": "watermark"},
json={"value": "2026-04-29"}
+ )
+
+ assert response.status_code == 204
+ with create_session() as session:
+ row = session.scalar(
+ select(AssetStateModel).where(
+ AssetStateModel.asset_id == asset.id,
+ AssetStateModel.key == "watermark",
+ )
+ )
+ assert row is not None
+ assert row.value == "2026-04-29"
+
+ def test_put_overwrites_existing(self, client: TestClient, asset:
AssetModel):
+ client.put(_VALUE_URL, params={"name": asset.name, "key":
"watermark"}, json={"value": "2026-04-28"})
+
+ response = client.put(
+ _VALUE_URL, params={"name": asset.name, "key": "watermark"},
json={"value": "2026-04-29"}
+ )
+
+ assert response.status_code == 204
+ assert client.get(_VALUE_URL, params={"name": asset.name, "key":
"watermark"}).json() == {
+ "value": "2026-04-29"
+ }
+
+ def test_put_empty_body_returns_422(self, client: TestClient, asset:
AssetModel):
+ response = client.put(_VALUE_URL, params={"name": asset.name, "key":
"watermark"}, json={})
+
+ assert response.status_code == 422
+
+ def test_put_extra_field_returns_422(self, client: TestClient, asset:
AssetModel):
+ response = client.put(
+ _VALUE_URL, params={"name": asset.name, "key": "watermark"},
json={"value": "x", "extra": "y"}
+ )
+
+ assert response.status_code == 422
+
+ def test_put_unknown_asset_returns_404(self, client: TestClient):
+ response = client.put(
+ _VALUE_URL, params={"name": "nonexistent", "key": "watermark"},
json={"value": "x"}
+ )
+
+ assert response.status_code == 404
+ assert "nonexistent" in response.json()["detail"]["message"]
+
+
+class TestDeleteAssetState:
+ def test_delete_removes_key(self, client: TestClient, asset: AssetModel):
+ client.put(_VALUE_URL, params={"name": asset.name, "key":
"watermark"}, json={"value": "2026-04-29"})
+
+ response = client.delete(_VALUE_URL, params={"name": asset.name,
"key": "watermark"})
+
+ assert response.status_code == 204
+ assert client.get(_VALUE_URL, params={"name": asset.name, "key":
"watermark"}).status_code == 404
+
+ def test_delete_missing_key_is_noop(self, client: TestClient, asset:
AssetModel):
+ response = client.delete(_VALUE_URL, params={"name": asset.name,
"key": "never_existed"})
+
+ assert response.status_code == 204
+
+
+class TestClearAssetState:
+ def test_clear_removes_all_keys(self, client: TestClient, asset:
AssetModel):
+ for k, v in [("watermark", "a"), ("last_id", "b"), ("schema_hash",
"c")]:
+ client.put(_VALUE_URL, params={"name": asset.name, "key": k},
json={"value": v})
+
+ response = client.delete(_CLEAR_URL, params={"name": asset.name})
+
+ assert response.status_code == 204
+ with create_session() as session:
+ row =
session.scalar(select(AssetStateModel).where(AssetStateModel.asset_id ==
asset.id))
+ assert row is None
+
+
+class TestInactiveAssetRejected:
+ """An asset row without a corresponding asset_active entry is treated as
not found."""
+
+ def test_get_inactive_asset_returns_404(self, client: TestClient,
inactive_asset: AssetModel):
+ response = client.get(_VALUE_URL, params={"name": inactive_asset.name,
"key": "watermark"})
+ assert response.status_code == 404
+
+ def test_put_inactive_asset_returns_404(self, client: TestClient,
inactive_asset: AssetModel):
+ response = client.put(
+ _VALUE_URL, params={"name": inactive_asset.name, "key":
"watermark"}, json={"value": "x"}
+ )
+ assert response.status_code == 404
diff --git
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_state.py
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_state.py
new file mode 100644
index 00000000000..8a66a0a23c7
--- /dev/null
+++
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_state.py
@@ -0,0 +1,274 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from uuid import uuid4
+
+import pytest
+from fastapi import Request
+from fastapi.testclient import TestClient
+from sqlalchemy import delete, select
+
+from airflow._shared.timezones import timezone
+from airflow.api_fastapi.app import cached_app
+from airflow.api_fastapi.execution_api.datamodels.token import TIClaims,
TIToken
+from airflow.api_fastapi.execution_api.security import _jwt_bearer
+from airflow.models.dagrun import DagRun
+from airflow.models.task_state import TaskStateModel
+from airflow.utils.session import create_session
+
+if TYPE_CHECKING:
+ from tests_common.pytest_plugin import CreateTaskInstance
+
+
+pytestmark = pytest.mark.db_test
+
+
[email protected](autouse=True)
+def reset_state_tables():
+ with create_session() as session:
+ session.execute(delete(TaskStateModel))
+ session.execute(delete(DagRun))
+
+
+def _api_url(ti_id, key: str | None = None) -> str:
+ base = f"/execution/state/ti/{ti_id}"
+ return f"{base}/{key}" if key else base
+
+
+class TestGetTaskState:
+ 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"})
+
+ response = client.get(_api_url(ti.id, "job_id"))
+
+ assert response.status_code == 200
+ assert response.json() == {"value": "spark_001"}
+
+ def test_get_missing_key_returns_404(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+
+ response = client.get(_api_url(ti.id, "never_set"))
+
+ assert response.status_code == 404
+ assert response.json()["detail"]["reason"] == "not_found"
+
+ def test_get_missing_ti_returns_404(self, client: TestClient):
+ response = client.get(_api_url(uuid4(), "any_key"))
+
+ assert response.status_code == 404
+ assert "Task instance" in response.json()["detail"]["message"]
+
+
+class TestPutTaskState:
+ def test_put_creates_row(self, client: TestClient, create_task_instance:
CreateTaskInstance):
+ ti = create_task_instance()
+
+ response = client.put(_api_url(ti.id, "job_id"), json={"value":
"spark_001"})
+
+ assert response.status_code == 204
+ with create_session() as session:
+ row = session.scalar(
+ select(TaskStateModel).where(
+ TaskStateModel.dag_id == ti.dag_id,
+ TaskStateModel.run_id == ti.run_id,
+ TaskStateModel.task_id == ti.task_id,
+ TaskStateModel.key == "job_id",
+ )
+ )
+ assert row is not None
+ assert row.value == "spark_001"
+
+ def test_put_overwrites_existing(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+ client.put(_api_url(ti.id, "job_id"), json={"value": "spark_001"})
+
+ response = client.put(_api_url(ti.id, "job_id"), json={"value":
"spark_002"})
+
+ assert response.status_code == 204
+ assert client.get(_api_url(ti.id, "job_id")).json() == {"value":
"spark_002"}
+
+ def test_put_empty_body_returns_422(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+
+ response = client.put(_api_url(ti.id, "job_id"), json={})
+
+ assert response.status_code == 422
+
+ def test_put_extra_field_returns_422(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+
+ response = client.put(_api_url(ti.id, "job_id"), json={"value": "x",
"extra": "y"})
+
+ assert response.status_code == 422
+
+ def test_put_null_value_returns_422(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+
+ response = client.put(_api_url(ti.id, "job_id"), json={"value": None})
+
+ assert response.status_code == 422
+
+ def test_put_missing_ti_returns_404(self, client: TestClient):
+ response = client.put(_api_url(uuid4(), "job_id"), json={"value": "x"})
+
+ assert response.status_code == 404
+
+
+class TestDeleteTaskState:
+ def test_delete_removes_key(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+ client.put(_api_url(ti.id, "job_id"), json={"value": "spark_001"})
+
+ response = client.delete(_api_url(ti.id, "job_id"))
+
+ assert response.status_code == 204
+ assert client.get(_api_url(ti.id, "job_id")).status_code == 404
+
+ def test_delete_missing_key_is_noop(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+
+ response = client.delete(_api_url(ti.id, "never_existed"))
+
+ assert response.status_code == 204
+
+ def test_delete_only_targets_one_key(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+ client.put(_api_url(ti.id, "job_id"), json={"value": "a"})
+ client.put(_api_url(ti.id, "checkpoint"), json={"value": "b"})
+
+ client.delete(_api_url(ti.id, "job_id"))
+
+ assert client.get(_api_url(ti.id, "job_id")).status_code == 404
+ assert client.get(_api_url(ti.id, "checkpoint")).json() == {"value":
"b"}
+
+
+class TestClearTaskState:
+ def test_clear_removes_all_keys(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+ for k, v in [("job_id", "a"), ("checkpoint", "b"), ("retry_count",
"c")]:
+ client.put(_api_url(ti.id, k), json={"value": v})
+
+ response = client.delete(_api_url(ti.id))
+
+ assert response.status_code == 204
+ with create_session() as session:
+ remaining = session.scalars(
+ select(TaskStateModel.key).where(
+ TaskStateModel.dag_id == ti.dag_id,
+ TaskStateModel.task_id == ti.task_id,
+ )
+ ).all()
+ assert remaining == []
+
+ def test_clear_when_empty_is_noop(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+
+ response = client.delete(_api_url(ti.id))
+
+ assert response.status_code == 204
+
+ def _seed_fleet_rows(self, ti, indices: tuple[int, ...]) -> None:
+ with create_session() as session:
+ now = timezone.utcnow()
+ for idx in indices:
+ session.add(
+ TaskStateModel(
+ dag_run_id=ti.dag_run.id,
+ dag_id=ti.dag_id,
+ run_id=ti.run_id,
+ task_id=ti.task_id,
+ map_index=idx,
+ key="job_id",
+ value=f"app_{idx}",
+ updated_at=now,
+ )
+ )
+ session.commit()
+
+ def test_clear_default_only_clears_this_map_index(
+ self, client: TestClient, create_task_instance: CreateTaskInstance
+ ):
+ """Clear without the query param only wipes the requesting TI's own
map_index."""
+ ti = create_task_instance(map_index=2)
+ self._seed_fleet_rows(ti, (0, 1, 2))
+
+ response = client.delete(_api_url(ti.id))
+
+ assert response.status_code == 204
+ with create_session() as session:
+ remaining_indices = sorted(
+ session.scalars(
+ select(TaskStateModel.map_index).where(
+ TaskStateModel.dag_id == ti.dag_id,
+ TaskStateModel.task_id == ti.task_id,
+ )
+ ).all()
+ )
+ assert remaining_indices == [0, 1]
+
+ def test_clear_with_all_map_indices_query_param_wipes_fleet(
+ self, client: TestClient, create_task_instance: CreateTaskInstance
+ ):
+ """Clear with ?all_map_indices=true wipes state for every mapped
instance."""
+ ti = create_task_instance(map_index=2)
+ self._seed_fleet_rows(ti, (0, 1, 2))
+
+ response = client.delete(_api_url(ti.id), params={"all_map_indices":
"true"})
+
+ assert response.status_code == 204
+ with create_session() as session:
+ remaining = session.scalars(
+ select(TaskStateModel).where(
+ TaskStateModel.dag_id == ti.dag_id,
+ TaskStateModel.task_id == ti.task_id,
+ )
+ ).all()
+ assert remaining == []
+
+
+class TestTiSelfEnforcement:
+ @pytest.fixture
+ def wrong_ti_client(self):
+ """TestClient using the real require_auth, JWT bound to a random TI
UUID."""
+ app = cached_app(apps="execution")
+ other_ti_id = uuid4()
+
+ async def mock_jwt(request: Request) -> TIToken:
+ return TIToken(id=other_ti_id, claims=TIClaims(scope="execution"))
+
+ app.dependency_overrides[_jwt_bearer] = mock_jwt
+ with TestClient(app, headers={"Authorization": "Bearer fake"}) as
client:
+ yield client
+ app.dependency_overrides.pop(_jwt_bearer, None)
+
+ def test_get_with_wrong_ti_token_returns_403(self, wrong_ti_client:
TestClient, create_task_instance):
+ ti = create_task_instance()
+ response = wrong_ti_client.get(_api_url(ti.id, "some_key"))
+ assert response.status_code == 403
+
+ def test_put_with_wrong_ti_token_returns_403(self, wrong_ti_client:
TestClient, create_task_instance):
+ ti = create_task_instance()
+ response = wrong_ti_client.put(_api_url(ti.id, "some_key"),
json={"value": "x"})
+ assert response.status_code == 403
+
+ def test_clear_with_wrong_ti_token_returns_403(self, wrong_ti_client:
TestClient, create_task_instance):
+ ti = create_task_instance()
+ response = wrong_ti_client.delete(_api_url(ti.id))
+ assert response.status_code == 403
diff --git a/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
b/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
index 5a8099c1ef3..b5b100154c3 100644
--- a/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
+++ b/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
@@ -63,6 +63,28 @@ class AssetProfile(BaseModel):
type: Annotated[str, Field(title="Type")]
+class AssetStatePutBody(BaseModel):
+ """
+ Request body for setting an asset state value.
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ value: Annotated[str, Field(title="Value")]
+
+
+class AssetStateResponse(BaseModel):
+ """
+ Asset state value returned to a worker.
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ value: Annotated[str, Field(title="Value")]
+
+
class ConnectionResponse(BaseModel):
"""
Connection schema for responses with fields that are needed for Runtime.
@@ -345,6 +367,28 @@ class TaskInstanceState(str, Enum):
DEFERRED = "deferred"
+class TaskStatePutBody(BaseModel):
+ """
+ Request body for setting a task state value.
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ value: Annotated[str, Field(title="Value")]
+
+
+class TaskStateResponse(BaseModel):
+ """
+ Task state value returned to a worker.
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ value: Annotated[str, Field(title="Value")]
+
+
class TaskStatesResponse(BaseModel):
"""
Response for task states with run_id, task and state.