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 a327dd4a733 Return 410 for stale id for set rtif API (#68902)
a327dd4a733 is described below

commit a327dd4a73373f4f8927d22ed7512c43fa2bc23c
Author: PoAn Yang <[email protected]>
AuthorDate: Tue Jul 7 16:04:48 2026 +0900

    Return 410 for stale id for set rtif API (#68902)
    
    When a task whose operator sets overwrite_rtif_after_execution=True leaves
    RUNNING via a retry or a clear, the server regenerates the task instance id
    and archives the old one. finalize() still overwrites RTIF with the stale 
id,
    so the API server returns 404 and the worker logs a spurious
    error traceback on top of the task's real outcome. The RTIF will be wrote 
in next id.
    
    Signed-off-by: PoAn Yang <[email protected]>
---
 .../execution_api/routes/task_instances.py         | 59 ++++++++++++----------
 .../versions/head/test_task_instances.py           | 32 +++++++++++-
 .../src/airflow/sdk/execution_time/supervisor.py   | 10 +++-
 .../task_sdk/execution_time/test_supervisor.py     | 39 ++++++++++++++
 4 files changed, 110 insertions(+), 30 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
index b813c4c8f7e..c1bac796023 100644
--- 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
+++ 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
@@ -22,7 +22,7 @@ import itertools
 import json
 from collections import defaultdict
 from collections.abc import Iterator
-from typing import TYPE_CHECKING, Annotated, Any, cast
+from typing import TYPE_CHECKING, Annotated, Any, NoReturn, cast
 from uuid import UUID
 
 import attrs
@@ -860,6 +860,29 @@ def ti_skip_downstream(
     log.info("Downstream tasks skipped", tasks_skipped=getattr(result, 
"rowcount", 0))
 
 
+def _raise_ti_not_in_live_table(task_instance_id: UUID, session: SessionDep) 
-> NoReturn:
+    """Raise 410 Gone if the missing TI id was archived to history, else 404 
Not Found."""
+    if session.scalar(
+        select(func.count(TIH.task_instance_id)).where(TIH.task_instance_id == 
task_instance_id)
+    ):
+        log.error("TaskInstance not in live table but archived in history", 
ti_id=str(task_instance_id))
+        raise HTTPException(
+            status_code=status.HTTP_410_GONE,
+            detail={
+                "reason": "not_found",
+                "message": "Task Instance not found, it may have been moved to 
the Task Instance History table",
+            },
+        )
+    log.error("Task Instance not found", ti_id=str(task_instance_id))
+    raise HTTPException(
+        status_code=status.HTTP_404_NOT_FOUND,
+        detail={
+            "reason": "not_found",
+            "message": "Task Instance not found",
+        },
+    )
+
+
 @ti_id_router.put(
     "/{task_instance_id}/heartbeat",
     status_code=status.HTTP_204_NO_CONTENT,
@@ -920,29 +943,7 @@ def ti_heartbeat(
         # Check if the TI exists in the Task Instance History table.
         # If it does, it was likely cleared while running, so return 410 Gone
         # instead of 404 Not Found to give the client a more specific signal.
-        tih_exists = session.scalar(
-            
select(func.count(TIH.task_instance_id)).where(TIH.task_instance_id == 
task_instance_id)
-        )
-        if tih_exists:
-            log.error(
-                "TaskInstance was previously cleared and archived in history, 
heartbeat skipped",
-                ti_id=str(task_instance_id),
-            )
-            raise HTTPException(
-                status_code=status.HTTP_410_GONE,
-                detail={
-                    "reason": "not_found",
-                    "message": "Task Instance not found, it may have been 
moved to the Task Instance History table",
-                },
-            )
-        log.error("Task Instance not found")
-        raise HTTPException(
-            status_code=status.HTTP_404_NOT_FOUND,
-            detail={
-                "reason": "not_found",
-                "message": "Task Instance not found",
-            },
-        )
+        _raise_ti_not_in_live_table(task_instance_id, session)
 
     if hostname != ti_payload.hostname or pid != ti_payload.pid:
         log.warning(
@@ -989,6 +990,10 @@ def ti_heartbeat(
     responses=create_openapi_http_exception_doc(
         [
             (status.HTTP_404_NOT_FOUND, "Task Instance not found"),
+            (
+                status.HTTP_410_GONE,
+                "Task Instance not found in the TI table but exists in the 
Task Instance History table",
+            ),
             (
                 HTTP_422_UNPROCESSABLE_CONTENT,
                 "Invalid payload for the setting rendered task instance 
fields",
@@ -1007,10 +1012,8 @@ def ti_put_rtif(
 
     task_instance = session.scalar(select(TI).where(TI.id == task_instance_id))
     if not task_instance:
-        log.error("Task Instance not found")
-        raise HTTPException(
-            status_code=status.HTTP_404_NOT_FOUND,
-        )
+        # On retry/clear, the server regenerates the TI id. Return 410 for the 
stale id.
+        _raise_ti_not_in_live_table(task_instance_id, session)
     task_instance.update_rtif(put_rtif_payload, session=session)
     log.debug("RenderedTaskInstanceFields updated successfully")
 
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
index da6ea74337d..542ce7eaaf1 100644
--- 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
@@ -2921,7 +2921,37 @@ class TestTIPutRTIF:
         random_id = uuid6.uuid7()
         response = client.put(f"/execution/task-instances/{random_id}/rtif", 
json=payload)
         assert response.status_code == 404
-        assert response.json()["detail"] == "Not Found"
+        assert response.json()["detail"] == {
+            "reason": "not_found",
+            "message": "Task Instance not found",
+        }
+
+    def test_ti_put_rtif_archived_ti_returns_410(self, client, session, 
create_task_instance):
+        ti = create_task_instance(
+            task_id="test_ti_put_rtif_archived",
+            state=State.RUNNING,
+            session=session,
+        )
+        session.commit()
+        old_ti_id = ti.id
+
+        # Archive the current try to TIH and assign a new UUID, mirroring 
prepare_db_for_next_try().
+        ti.prepare_db_for_next_try(session)
+        session.commit()
+
+        assert session.get(TaskInstance, old_ti_id) is None
+        assert session.get(TaskInstanceHistory, old_ti_id) is not None
+
+        response = client.put(
+            f"/execution/task-instances/{old_ti_id}/rtif",
+            json={"field1": "rendered_value1"},
+        )
+
+        assert response.status_code == 410
+        assert response.json()["detail"] == {
+            "reason": "not_found",
+            "message": "Task Instance not found, it may have been moved to the 
Task Instance History table",
+        }
 
 
 class TestPreviousDagRun:
diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py 
b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index 5a5e3852fd7..a96523d5a6a 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -1737,7 +1737,15 @@ class ActivitySubprocess(WatchedSubprocess):
         elif isinstance(msg, PutVariable):
             resp, dump_opts = handle_put_variable(self.client, msg)
         elif isinstance(msg, SetRenderedFields):
-            self.client.task_instances.set_rtif(self.id, msg.rendered_fields)
+            try:
+                self.client.task_instances.set_rtif(self.id, 
msg.rendered_fields)
+            except ServerResponseError as e:
+                # On retry/clear the server replaces the TI id (archiving the 
old one), so a late RTIF
+                # overwrite from finalize() lands on an id that no longer 
exists. Supervisor kills such
+                # a worker when handling 410 heartbeat response. We only need 
to skip this stale overwrite here.
+                if e.response.status_code != HTTPStatus.GONE:
+                    raise
+                log.debug("Skipping RTIF overwrite; task instance archived on 
retry/clear", ti_id=self.id)
         elif isinstance(msg, SetRenderedMapIndex):
             self.client.task_instances.set_rendered_map_index(self.id, 
msg.rendered_map_index)
         elif isinstance(msg, GetAssetByName):
diff --git a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py 
b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
index 491a1b0e3fd..9bf0ef9d13b 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
@@ -3092,6 +3092,45 @@ class TestHandleRequest:
             "detail": error.response.json(),
         }
 
+    @pytest.mark.parametrize(
+        ("status_code", "expects_error"),
+        [
+            pytest.param(410, False, id="410_gone_is_swallowed"),
+            pytest.param(404, True, id="404_not_found_propagates"),
+        ],
+    )
+    def test_set_rendered_fields_swallows_410_but_propagates_404(
+        self, watched_subprocess, mocker, status_code, expects_error
+    ):
+        """A stale-id RTIF overwrite (410) is skipped silently; a bogus-id 
(404) still propagates as an error."""
+        watched_subprocess, read_socket = watched_subprocess
+
+        error = ServerResponseError(
+            message="boom",
+            request=httpx.Request("PUT", "http://test";),
+            response=httpx.Response(status_code, json={"detail": "boom"}),
+        )
+        watched_subprocess.client.task_instances.set_rtif = 
mocker.Mock(side_effect=error)
+
+        generator = watched_subprocess.handle_requests(log=mocker.Mock())
+        next(generator)
+
+        msg = SetRenderedFields(rendered_fields={"field1": "v1"})
+        req_frame = _RequestFrame(id=randint(1, 2**32 - 1), 
body=msg.model_dump())
+        generator.send(req_frame)
+
+        read_socket.settimeout(0.1)
+        frame_len = int.from_bytes(read_socket.recv(4), "big")
+        frame = 
msgspec.msgpack.Decoder(_ResponseFrame).decode(read_socket.recv(frame_len))
+
+        assert frame.id == req_frame.id
+        if expects_error:
+            assert frame.error is not None
+            assert frame.error["error"] == "API_SERVER_ERROR"
+            assert frame.error["detail"]["status_code"] == status_code
+        else:
+            assert frame.error is None
+
     def test_handle_requests_network_exception_does_not_crash_loop(self, 
watched_subprocess, mocker):
         """A transient network error must not crash the IPC generator.
 

Reply via email to