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 f624c4e94d2 [v3-3-test] Fix task state store Execution API rejecting
keys that contain slashes (#69178) (#70967)
f624c4e94d2 is described below
commit f624c4e94d2011ed5fb8cfee040331e213106c00
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Aug 4 11:09:14 2026 +0530
[v3-3-test] Fix task state store Execution API rejecting keys that contain
slashes (#69178) (#70967)
* Fix task state store API key path
* Revert uv.lock
* Add client side
* Simplified the test
(cherry picked from commit 4899e9041859db58fabeb87f231e454a5f65a418)
Co-authored-by: Takayoshi Makabe
<[email protected]>
---
.../execution_api/routes/task_state_store.py | 6 ++--
.../execution_api/versions/v2026_06_30.py | 6 ++--
.../versions/head/test_task_state_store.py | 26 ++++++++++++++
task-sdk/src/airflow/sdk/api/client.py | 6 ++--
task-sdk/tests/task_sdk/api/test_client.py | 41 ++++++++++++++++++++++
5 files changed, 76 insertions(+), 9 deletions(-)
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 624926fe639..e1dfe7fcd97 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
@@ -58,7 +58,7 @@ def _get_task_scope_for_ti(task_instance_id: UUID, session:
Session) -> TaskScop
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}")
[email protected]("/{task_instance_id}/{key:path}")
def get_task_state_store(
task_instance_id: UUID,
key: Annotated[str, Path(min_length=1)],
@@ -78,7 +78,7 @@ def get_task_state_store(
return TaskStateStoreResponse(value=json.loads(value))
[email protected]("/{task_instance_id}/{key}",
status_code=status.HTTP_204_NO_CONTENT)
[email protected]("/{task_instance_id}/{key:path}",
status_code=status.HTTP_204_NO_CONTENT)
def set_task_state_store(
task_instance_id: UUID,
key: Annotated[str, Path(min_length=1)],
@@ -90,7 +90,7 @@ def set_task_state_store(
get_state_backend().set(scope, key, json.dumps(body.value),
expires_at=body.expires_at, session=session)
[email protected]("/{task_instance_id}/{key}",
status_code=status.HTTP_204_NO_CONTENT)
[email protected]("/{task_instance_id}/{key:path}",
status_code=status.HTTP_204_NO_CONTENT)
def delete_task_state_store(
task_instance_id: UUID,
key: Annotated[str, Path(min_length=1)],
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_06_30.py
b/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_06_30.py
index e89e2ed04cc..930bd1426a4 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_06_30.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_06_30.py
@@ -114,9 +114,9 @@ class AddTaskAndAssetStateStoreEndpoints(VersionChange):
description = __doc__
instructions_to_migrate_to_previous_version = (
- endpoint("/store/ti/{task_instance_id}/{key}", ["GET"]).didnt_exist,
- endpoint("/store/ti/{task_instance_id}/{key}", ["PUT"]).didnt_exist,
- endpoint("/store/ti/{task_instance_id}/{key}", ["DELETE"]).didnt_exist,
+ endpoint("/store/ti/{task_instance_id}/{key:path}",
["GET"]).didnt_exist,
+ endpoint("/store/ti/{task_instance_id}/{key:path}",
["PUT"]).didnt_exist,
+ endpoint("/store/ti/{task_instance_id}/{key:path}",
["DELETE"]).didnt_exist,
endpoint("/store/ti/{task_instance_id}", ["DELETE"]).didnt_exist,
endpoint("/store/asset/by-name/value", ["GET"]).didnt_exist,
endpoint("/store/asset/by-name/value", ["PUT"]).didnt_exist,
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 f544de4f95e..671ceffbb4a 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
@@ -78,6 +78,15 @@ class TestGetTaskState:
assert response.status_code == 404
assert "Task instance" in response.json()["detail"]["message"]
+ def test_get_key_with_slash(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+ client.put(_api_url(ti.id, "spark/job_id"), json={"value":
"spark_001"})
+
+ response = client.get(_api_url(ti.id, "spark/job_id"))
+
+ assert response.status_code == 200
+ assert response.json() == {"value": "spark_001"}
+
class TestPutTaskState:
def test_put_creates_row(self, client: TestClient, create_task_instance:
CreateTaskInstance):
@@ -203,6 +212,14 @@ class TestPutTaskState:
assert response.status_code == 404
+ def test_put_key_with_slash(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+
+ response = client.put(_api_url(ti.id, "spark/job_id"), json={"value":
"spark_001"})
+
+ assert response.status_code == 204
+ assert client.get(_api_url(ti.id, "spark/job_id")).json() == {"value":
"spark_001"}
+
class TestDeleteTaskState:
def test_delete_removes_key(self, client: TestClient,
create_task_instance: CreateTaskInstance):
@@ -231,6 +248,15 @@ class TestDeleteTaskState:
assert client.get(_api_url(ti.id, "job_id")).status_code == 404
assert client.get(_api_url(ti.id, "checkpoint")).json() == {"value":
"b"}
+ def test_delete_key_with_slash(self, client: TestClient,
create_task_instance: CreateTaskInstance):
+ ti = create_task_instance()
+ client.put(_api_url(ti.id, "spark/job_id"), json={"value":
"spark_001"})
+
+ response = client.delete(_api_url(ti.id, "spark/job_id"))
+
+ assert response.status_code == 204
+ assert client.get(_api_url(ti.id, "spark/job_id")).status_code == 404
+
class TestClearTaskState:
def test_clear_removes_all_keys(self, client: TestClient,
create_task_instance: CreateTaskInstance):
diff --git a/task-sdk/src/airflow/sdk/api/client.py
b/task-sdk/src/airflow/sdk/api/client.py
index 037ddd7eb5f..8e0a46a477a 100644
--- a/task-sdk/src/airflow/sdk/api/client.py
+++ b/task-sdk/src/airflow/sdk/api/client.py
@@ -724,7 +724,7 @@ class TaskStateStoreOperations:
def get(self, ti_id: uuid.UUID, key: str) -> TaskStateStoreResponse |
ErrorResponse:
"""Get a task store value from the API server."""
try:
- resp = self.client.get(f"store/ti/{ti_id}/{key}")
+ resp = self.client.get(f"store/ti/{ti_id}/{quote(key, safe='')}")
except ServerResponseError as e:
if e.response.status_code == HTTPStatus.NOT_FOUND:
log.debug("Task store key not found", ti_id=ti_id, key=key)
@@ -735,12 +735,12 @@ class TaskStateStoreOperations:
def set(self, ti_id: uuid.UUID, key: str, value: JsonValue, expires_at:
datetime | None) -> OKResponse:
"""Set a task store value via the API server."""
body = TaskStateStorePutBody(value=value, expires_at=expires_at)
- self.client.put(f"store/ti/{ti_id}/{key}",
content=body.model_dump_json())
+ self.client.put(f"store/ti/{ti_id}/{quote(key, safe='')}",
content=body.model_dump_json())
return OKResponse(ok=True)
def delete(self, ti_id: uuid.UUID, key: str) -> OKResponse:
"""Delete a single task store key via the API server."""
- self.client.delete(f"store/ti/{ti_id}/{key}")
+ self.client.delete(f"store/ti/{ti_id}/{quote(key, safe='')}")
return OKResponse(ok=True)
def clear(self, ti_id: uuid.UUID) -> OKResponse:
diff --git a/task-sdk/tests/task_sdk/api/test_client.py
b/task-sdk/tests/task_sdk/api/test_client.py
index 92b970f7ee5..5c4bac56972 100644
--- a/task-sdk/tests/task_sdk/api/test_client.py
+++ b/task-sdk/tests/task_sdk/api/test_client.py
@@ -1831,6 +1831,19 @@ class TestTaskStateOperations:
assert isinstance(result, TaskStateStoreResponse)
assert result.value == "spark_app_001"
+ def test_get_url_encodes_key_with_slash(self):
+ requests_seen = []
+
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ requests_seen.append(request)
+ return httpx.Response(status_code=200, json={"value":
"spark_app_001"})
+
+ client = make_client(transport=httpx.MockTransport(handle_request))
+ client.task_state_store.get(ti_id=self.TI_ID, key="spark/job_id")
+
+ assert b"%2F" in requests_seen[0].url.raw_path
+ assert requests_seen[0].url.raw_path ==
f"/store/ti/{self.TI_ID}/spark%2Fjob_id".encode()
+
def test_get_returns_error_response_on_404(self):
def handle_request(request: httpx.Request) -> httpx.Response:
return httpx.Response(
@@ -1858,6 +1871,21 @@ class TestTaskStateOperations:
)
assert result == OKResponse(ok=True)
+ def test_set_url_encodes_key_with_slash(self):
+ requests_seen = []
+
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ requests_seen.append(request)
+ return httpx.Response(status_code=204)
+
+ client = make_client(transport=httpx.MockTransport(handle_request))
+ client.task_state_store.set(
+ ti_id=self.TI_ID, key="spark/job_id", value="spark_app_001",
expires_at=None
+ )
+
+ assert b"%2F" in requests_seen[0].url.raw_path
+ assert requests_seen[0].url.raw_path ==
f"/store/ti/{self.TI_ID}/spark%2Fjob_id".encode()
+
def test_set_with_expires_at_sends_field(self):
"""expires_at is forwarded as an ISO datetime string in the request
body."""
expires = datetime(2026, 5, 21, 12, 0, 0, tzinfo=dt_timezone.utc)
@@ -1901,6 +1929,19 @@ class TestTaskStateOperations:
result = client.task_state_store.delete(ti_id=self.TI_ID, key="job_id")
assert result == OKResponse(ok=True)
+ def test_delete_url_encodes_key_with_slash(self):
+ requests_seen = []
+
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ requests_seen.append(request)
+ return httpx.Response(status_code=204)
+
+ client = make_client(transport=httpx.MockTransport(handle_request))
+ client.task_state_store.delete(ti_id=self.TI_ID, key="spark/job_id")
+
+ assert b"%2F" in requests_seen[0].url.raw_path
+ assert requests_seen[0].url.raw_path ==
f"/store/ti/{self.TI_ID}/spark%2Fjob_id".encode()
+
def test_clear_sends_delete_request(self):
def handle_request(request: httpx.Request) -> httpx.Response:
assert request.method == "DELETE"