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

weilee 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 6cc2232a79d fix(hitl): handle hitl details when task instance is 
retried (#53824)
6cc2232a79d is described below

commit 6cc2232a79d19bea4b0dc3df388623541aef2d4b
Author: Wei Lee <[email protected]>
AuthorDate: Tue Jul 29 06:29:39 2025 +0300

    fix(hitl): handle hitl details when task instance is retried (#53824)
---
 .../api_fastapi/execution_api/routes/hitl.py       |  53 ++++++----
 .../execution_api/versions/head/test_hitl.py       | 116 ++++++++++++---------
 .../airflow/providers/standard/operators/hitl.py   |   4 +-
 task-sdk/src/airflow/sdk/execution_time/hitl.py    |   2 +-
 .../tests/task_sdk/execution_time/test_hitl.py     |   6 +-
 5 files changed, 110 insertions(+), 71 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py
index e1f3736662b..36d50b19fa7 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py
@@ -40,32 +40,49 @@ log = structlog.get_logger(__name__)
     "/{task_instance_id}",
     status_code=status.HTTP_201_CREATED,
 )
-def add_hitl_detail(
+def upsert_hitl_detail(
     task_instance_id: UUID,
     payload: HITLDetailRequest,
     session: SessionDep,
 ) -> HITLDetailRequest:
-    """Get Human-in-the-loop detail for a specific Task Instance."""
+    """
+    Create a Human-in-the-loop detail for a specific Task Instance.
+
+    There're 3 cases handled here.
+
+    1. If a HITLOperator task instance does not have a HITLDetail,
+       a new HITLDetail is created without a response section.
+    2. If a HITLOperator task instance has a HITLDetail but lacks a response,
+       the existing HITLDetail is returned.
+       This situation occurs when a task instance is cleared before a response 
is received.
+    3. If a HITLOperator task instance has both a HITLDetail and a response 
section,
+       the existing response is removed, and the HITLDetail is returned.
+       This happens when a task instance is cleared after a response has been 
received.
+       This design ensures that each task instance has only one HITLDetail.
+    """
     ti_id_str = str(task_instance_id)
     hitl_detail_model = 
session.scalar(select(HITLDetail).where(HITLDetail.ti_id == ti_id_str))
-    if hitl_detail_model:
-        raise HTTPException(
-            status.HTTP_409_CONFLICT,
-            f"Human-in-the-loop detail for Task Instance with id {ti_id_str} 
already exists.",
+    if not hitl_detail_model:
+        hitl_detail_model = HITLDetail(
+            ti_id=ti_id_str,
+            options=payload.options,
+            subject=payload.subject,
+            body=payload.body,
+            defaults=payload.defaults,
+            multiple=payload.multiple,
+            params=payload.params,
         )
+        session.add(hitl_detail_model)
+    elif hitl_detail_model.response_received:
+        # Cleanup the response part of HITLDetail as we only store one 
response for one task instance.
+        # It normally happens after retry, we keep only the latest response.
+        hitl_detail_model.user_id = None
+        hitl_detail_model.response_at = None
+        hitl_detail_model.chosen_options = None
+        hitl_detail_model.params_input = {}
+        session.add(hitl_detail_model)
 
-    hitl_detail = HITLDetail(
-        ti_id=ti_id_str,
-        options=payload.options,
-        subject=payload.subject,
-        body=payload.body,
-        defaults=payload.defaults,
-        multiple=payload.multiple,
-        params=payload.params,
-    )
-    session.add(hitl_detail)
-    session.commit()
-    return HITLDetailRequest.model_validate(hitl_detail)
+    return HITLDetailRequest.model_validate(hitl_detail_model)
 
 
 @router.patch("/{task_instance_id}")
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_hitl.py 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_hitl.py
index 33247304770..ec61542c485 100644
--- 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_hitl.py
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_hitl.py
@@ -16,43 +16,60 @@
 # under the License.
 from __future__ import annotations
 
-from datetime import datetime
-
 import pytest
-import time_machine
-from uuid6 import uuid7
+from httpx import Client
 
 from tests_common.test_utils.db import AIRFLOW_V_3_1_PLUS
 
 if not AIRFLOW_V_3_1_PLUS:
     pytest.skip("Human in the loop public API compatible with Airflow >= 
3.0.1", allow_module_level=True)
 
+from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
+import time_machine
+
+from airflow._shared.timezones.timezone import convert_to_utc
 from airflow.models.hitl import HITLDetail
 
 if TYPE_CHECKING:
+    from fastapi.testclient import TestClient
+    from sqlalchemy.orm import Session
+
     from airflow.models.taskinstance import TaskInstance
 
+    from tests_common.pytest_plugin import CreateTaskInstance
+
 pytestmark = pytest.mark.db_test
-TI_ID = uuid7()
+
+default_hitl_detail_request_kwargs: dict[str, Any] = {
+    # ti_id decided at a later stage
+    "subject": "This is subject",
+    "body": "this is body",
+    "options": ["Approve", "Reject"],
+    "defaults": ["Approve"],
+    "multiple": False,
+    "params": {"input_1": 1},
+}
+expected_empty_hitl_detail_response_part: dict[str, Any] = {
+    "response_at": None,
+    "chosen_options": None,
+    "user_id": None,
+    "params_input": {},
+    "response_received": False,
+}
 
 
 @pytest.fixture
-def sample_ti(create_task_instance) -> TaskInstance:
+def sample_ti(create_task_instance: CreateTaskInstance) -> TaskInstance:
     return create_task_instance()
 
 
 @pytest.fixture
-def sample_hitl_detail(session, sample_ti) -> HITLDetail:
+def sample_hitl_detail(session: Session, sample_ti: TaskInstance) -> 
HITLDetail:
     hitl_detail_model = HITLDetail(
         ti_id=sample_ti.id,
-        options=["Approve", "Reject"],
-        subject="This is subject",
-        body="this is body",
-        defaults=["Approve"],
-        multiple=False,
-        params={"input_1": 1},
+        **default_hitl_detail_request_kwargs,
     )
     session.add(hitl_detail_model)
     session.commit()
@@ -61,54 +78,65 @@ def sample_hitl_detail(session, sample_ti) -> HITLDetail:
 
 
 @pytest.fixture
-def expected_sample_hitl_detail_dict(sample_ti) -> dict[str, Any]:
+def expected_sample_hitl_detail_dict(sample_ti: TaskInstance) -> dict[str, 
Any]:
     return {
-        "body": "this is body",
-        "defaults": ["Approve"],
-        "multiple": False,
-        "options": ["Approve", "Reject"],
-        "params": {"input_1": 1},
-        "params_input": {},
-        "response_at": None,
-        "chosen_options": None,
-        "response_received": False,
-        "subject": "This is subject",
         "ti_id": sample_ti.id,
-        "user_id": None,
+        **default_hitl_detail_request_kwargs,
+        **expected_empty_hitl_detail_response_part,
     }
 
 
-def test_add_hitl_detail(client, create_task_instance, session) -> None:
[email protected](
+    "existing_hitl_detail_args",
+    [
+        None,
+        default_hitl_detail_request_kwargs,
+        {
+            **default_hitl_detail_request_kwargs,
+            **{
+                "params_input": {"input_1": 2},
+                "response_at": convert_to_utc(datetime(2025, 7, 3, 0, 0, 0)),
+                "chosen_options": ["Reject"],
+                "user_id": "Fallback to defaults",
+            },
+        },
+    ],
+    ids=[
+        "no existing hitl detail",
+        "existing hitl detail without response",
+        "existing hitl detail with response",
+    ],
+)
+def test_upsert_hitl_detail(
+    client: TestClient,
+    create_task_instance: CreateTaskInstance,
+    session: Session,
+    existing_hitl_detail_args: dict[str, Any],
+) -> None:
     ti = create_task_instance()
     session.commit()
 
+    if existing_hitl_detail_args:
+        session.add(HITLDetail(ti_id=ti.id, **existing_hitl_detail_args))
+        session.commit()
+
     response = client.post(
         f"/execution/hitl-details/{ti.id}",
         json={
             "ti_id": ti.id,
-            "options": ["Approve", "Reject"],
-            "subject": "This is subject",
-            "body": "this is body",
-            "defaults": ["Approve"],
-            "multiple": False,
-            "params": {"input_1": 1},
+            **default_hitl_detail_request_kwargs,
         },
     )
     assert response.status_code == 201
     assert response.json() == {
         "ti_id": ti.id,
-        "options": ["Approve", "Reject"],
-        "subject": "This is subject",
-        "body": "this is body",
-        "defaults": ["Approve"],
-        "multiple": False,
-        "params": {"input_1": 1},
+        **default_hitl_detail_request_kwargs,
     }
 
 
 @time_machine.travel(datetime(2025, 7, 3, 0, 0, 0), tick=False)
 @pytest.mark.usefixtures("sample_hitl_detail")
-def test_update_hitl_detail(client, sample_ti) -> None:
+def test_update_hitl_detail(client: Client, sample_ti: TaskInstance) -> None:
     response = client.patch(
         f"/execution/hitl-details/{sample_ti.id}",
         json={
@@ -128,13 +156,7 @@ def test_update_hitl_detail(client, sample_ti) -> None:
 
 
 @pytest.mark.usefixtures("sample_hitl_detail")
-def test_get_hitl_detail(client, sample_ti) -> None:
+def test_get_hitl_detail(client: Client, sample_ti: TaskInstance) -> None:
     response = client.get(f"/execution/hitl-details/{sample_ti.id}")
     assert response.status_code == 200
-    assert response.json() == {
-        "params_input": {},
-        "response_at": None,
-        "chosen_options": None,
-        "response_received": False,
-        "user_id": None,
-    }
+    assert response.json() == expected_empty_hitl_detail_response_part
diff --git 
a/providers/standard/src/airflow/providers/standard/operators/hitl.py 
b/providers/standard/src/airflow/providers/standard/operators/hitl.py
index 891a78908e4..f89b840c101 100644
--- a/providers/standard/src/airflow/providers/standard/operators/hitl.py
+++ b/providers/standard/src/airflow/providers/standard/operators/hitl.py
@@ -34,7 +34,7 @@ from airflow.providers.standard.exceptions import 
HITLTimeoutError, HITLTriggerE
 from airflow.providers.standard.triggers.hitl import HITLTrigger, 
HITLTriggerEventSuccessPayload
 from airflow.providers.standard.utils.skipmixin import SkipMixin
 from airflow.sdk.definitions.param import ParamsDict
-from airflow.sdk.execution_time.hitl import add_hitl_detail
+from airflow.sdk.execution_time.hitl import upsert_hitl_detail
 
 if TYPE_CHECKING:
     from airflow.sdk.definitions.context import Context
@@ -98,7 +98,7 @@ class HITLOperator(BaseOperator):
         """Add a Human-in-the-loop Response and then defer to HITLTrigger and 
wait for user input."""
         ti_id = context["task_instance"].id
         # Write Human-in-the-loop input request to DB
-        add_hitl_detail(
+        upsert_hitl_detail(
             ti_id=ti_id,
             options=self.options,
             subject=self.subject,
diff --git a/task-sdk/src/airflow/sdk/execution_time/hitl.py 
b/task-sdk/src/airflow/sdk/execution_time/hitl.py
index 6f6409b3dca..348f14b838a 100644
--- a/task-sdk/src/airflow/sdk/execution_time/hitl.py
+++ b/task-sdk/src/airflow/sdk/execution_time/hitl.py
@@ -30,7 +30,7 @@ if TYPE_CHECKING:
     from airflow.api_fastapi.execution_api.datamodels.hitl import 
HITLDetailResponse
 
 
-def add_hitl_detail(
+def upsert_hitl_detail(
     ti_id: UUID,
     options: list[str],
     subject: str,
diff --git a/task-sdk/tests/task_sdk/execution_time/test_hitl.py 
b/task-sdk/tests/task_sdk/execution_time/test_hitl.py
index a936812bbb7..5f7cd373484 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_hitl.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_hitl.py
@@ -23,16 +23,16 @@ from airflow.sdk import timezone
 from airflow.sdk.api.datamodels._generated import HITLDetailResponse
 from airflow.sdk.execution_time.comms import CreateHITLDetailPayload
 from airflow.sdk.execution_time.hitl import (
-    add_hitl_detail,
     get_hitl_detail_content_detail,
     update_htil_detail_response,
+    upsert_hitl_detail,
 )
 
 TI_ID = uuid7()
 
 
-def test_add_hitl_detail(mock_supervisor_comms) -> None:
-    add_hitl_detail(
+def test_upsert_hitl_detail(mock_supervisor_comms) -> None:
+    upsert_hitl_detail(
         ti_id=TI_ID,
         options=["Approve", "Reject"],
         subject="Subject",

Reply via email to